swatchkit
v5.4.0
Published
A lightweight tool for creating HTML pattern libraries.
Readme
SwatchKit
SwatchKit is a lightweight tool for generating HTML pattern libraries and design systems. It scans your folders for components, stitches them into a documentation site, and lets you share the same render functions between your app and the library.
The "Magic Folder" principle: drop files in, a library comes out.
The pattern: one source of truth
The interesting case is when your app and your pattern library share render functions. You write renderButton() once, use it in src/pages/home.js for the real app, and use it again in swatchkit/swatches/button/index.js for the docs. Edit one, both update.
my-project/
├── src/
│ ├── components/button.js # shared renderButton
│ ├── pages/home.js # calls renderButton → real app HTML
│ └── css/ # cssDir — your stylesheet source
│ └── global/tokens.css # design tokens (CSS, with @swatchkit blocks)
├── swatchkit/
│ ├── _swatchkit.html
│ ├── _preview.html
│ └── swatches/
│ └── button/
│ ├── index.js # calls renderButton → swatch HTML
│ └── demo.js # sibling asset, copied to dist
├── scripts/ # build scripts (from `init --app`)
│ ├── build-site.js # renders src/pages/home.js → dist/index.html
│ └── build-assets.js # esbuild bundles CSS + JS
├── swatchkit.config.js
└── dist/ # generated
├── index.html # main app (rendered at build time)
├── css/ # one copy, shared
└── swatchkit/
├── index.html # references ../css/main.css
└── preview/...Quick start
# 1. Create config + scaffold CSS blueprints, layout templates, starter tokens
# (picks CJS or ESM config based on package.json#type)
npx swatchkit init --cssDir ./src/css
# 2. Build
npx swatchkitswatchkit init prompts for cssDir (default ./src/css) when --cssDir is omitted, writes swatchkit.config.js, and scaffolds the project in one step. If you run swatchkit with no config file, the build falls back to ./css. The build output goes to dist/swatchkit/ by default.
Full app in one command
To scaffold a complete integrated app — esbuild build scripts, shared
renderButton/renderCard functions, a home page, two example swatches, and a
watch-enabled package.json — add --app:
mkdir my-app && cd my-app
# 1. Create package.json and make the project ESM FIRST (see note below).
npm init -y
npm pkg set type=module private=true
# 2. Install tooling, then scaffold + run.
npm install -D swatchkit esbuild
npx swatchkit init --app --cssDir ./src/css
npm install
npm run dev # builds, watches, serves at http://localhost:8080The app is at /, the pattern library at /swatchkit/, both sharing one bundled
CSS file. See the hand-rolled app setup guide.
Set
"type": "module"before runninginit --app. The app starter is ESM, so it writes an ESMswatchkit.config.js(export default { … }) and ESM build scripts. If yourpackage.jsonisn't"type": "module", Node loads the config as CommonJS and the build fails with:Warning: Failed to load the ES module: …/swatchkit.config.js. Make sure to set "type": "module" in the nearest package.json … [SwatchKit] Error: Unexpected token 'export'Fix it by setting ESM and re-running (use
--forceto refresh the config):npm pkg set type=module npx swatchkit init --app --cssDir ./src/css --forceThe plain
swatchkit init(without--app) doesn't need this — it picks CJS or ESM config syntax based on your existingpackage.json#type.
For real projects, install as a dev dep and add a script:
npm install -D swatchkit"scripts": {
"patterns": "swatchkit"
}The Magic Folder
SwatchKit reads from a single folder (swatchkit/ by default). Top-level folders under swatchkit/ become sidebar sections. Folders directly inside those sections become swatches.
swatchkit/
├── tokens/ # section: "Design Tokens" (generated — see below)
│ ├── colors.html
│ └── text-sizes.html
├── swatches/ # section: "Swatches"
│ ├── button/
│ │ └── index.html
│ └── card/
│ ├── index.html
│ ├── styles.css # sibling assets copied alongside
│ └── script.js
├── compositions/ # section: "Compositions"
│ └── sidebar/index.html
└── utilities/ # section: "Utilities"
└── flow/index.htmlRules:
- One level deep. A swatch is a folder with an
index.html(orindex.js, see below). Nested groups likeswatches/components/button/are not scanned — useswatches/button/. - Underscore prefix is ignored.
_wip/,_notes.md,_swatchkit.htmlall stay out of the build. - Sibling assets travel with the swatch. Anything next to
index.html(CSS, JS, images) is copied to the build output, so your swatch can reference them with relative paths. swatchkit/tokens/*.htmlis generated, not hand-written. SwatchKit regenerates it on every build from your@swatchkittoken blocks (see Design tokens). Don't edit those files.
JavaScript swatches
A swatch folder may use index.js instead of index.html to generate HTML programmatically. This is how you share render functions between your app and the library.
// swatchkit/swatches/button/index.js
import { renderButton } from "../../../src/components/button.js";
const html = String.raw;
export default html`
<h2>Button</h2>
${renderButton({ label: "Save changes" })}
${renderButton({ label: "View brand", href: "/brands/aurora/" })}
`;index.jsruns at build time and must default-export an HTML string. Non-string defaults fail with a clear error.- If both
index.jsandindex.htmlexist,index.jswins. index.jsis reserved for SwatchKit and is not copied as a preview asset. Other.jsfiles in the folder are copied (e.g.demo.jsfor browser-side scripts).- For JavaScript swatches that use
import/export, set"type": "module"in yourpackage.json. Without that, the swatch runs as CommonJS andimportsyntax is a parse error.
The renderer contract
A component renderer is a function with a specific shape. This is what makes the shared-between-app-and-library pattern work.
- 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
Props are an object, destructured with defaults for optional fields. Document the shape with a comment above the function so callers know what to pass.
Naming: render<ComponentName>. Verb-first makes the role obvious.
// Pure, deterministic, no imports beyond the function's needs.
export function renderButton({ label, href, variant = "primary" }) {
const className = variant === "outline" ? "button outline" : "button";
return href
? `<a class="${className}" href="${href}">${label}</a>`
: `<button class="${className}">${label}</button>`;
}For a full project that uses this contract end-to-end, see the hand-rolled app setup guide.
Two ways to ship CSS
SwatchKit's CSS behavior is controlled by cssCopy in the config. The default is true; for integrated apps you'll usually want false.
Self-contained (cssCopy: true)
SwatchKit copies cssDir into dist/swatchkit/css/. The pattern library is fully self-contained — you can serve just dist/swatchkit/ anywhere.
// swatchkit.config.js
export default { cssDir: "./src/css", cssCopy: true };dist/swatchkit/
├── css/main.css ← copied from src/css/
└── index.htmlUse this when the pattern library is the deliverable, or when you're just kicking the tires.
Integrated (cssCopy: false)
SwatchKit skips the copy and writes <link> tags pointing at cssPath instead. You put the CSS in dist/css/ once, the library references it, and your app's HTML references it too. No duplication.
// swatchkit.config.js
export default {
cssDir: "./src/css",
cssCopy: false,
cssPath: "../css/",
};dist/
├── index.html ← your app, links ./css/main.css
├── css/main.css ← one copy, shared
└── swatchkit/
├── index.html ← links ../css/main.css
└── preview/...cssPath is the path from swatchkit's HTML to the CSS. The default (when omitted) is ../<basename of cssDir>/ — so cssDir: "./src/css" defaults to "../css/". For most integrated projects, that's all you need. Set it explicitly only if your build puts CSS somewhere other than dist/css/.
For an integrated app, your package.json typically chains both steps. The canonical setup uses esbuild for the CSS+JS bundling step — see the hand-rolled app setup guide for the full reference:
{
"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",
"patterns": "swatchkit"
}
}build:swatchkit runs first so the freshly regenerated src/css/utilities/utilities.css (the utility classes derived from your @swatchkit token blocks) gets picked up by build:assets. Even with cssCopy: false, SwatchKit still regenerates that utilities file inside cssDir; it only skips copying CSS into dist/swatchkit/css/. Your tokens.css is never modified — it's the source of truth.
If you just want to kick the tires: cp -r src/css dist/css works in place of build-assets.js, but it doesn't bundle @imports and doesn't minify — use it for a prototype, then switch to the esbuild flow for anything beyond that.
For a complete walkthrough of an integrated setup (with the full clean.js, build-site.js, and build-assets.js scripts), see the hand-rolled app setup guide.
Configuration
swatchkit.config.js at the project root. All options are optional.
export default {
// Pattern source directory (default: "./swatchkit")
input: "./patterns",
// Output directory (default: "./dist/swatchkit")
outDir: "./dist/patterns",
// CSS source directory.
// `swatchkit init` defaults this to "./src/css".
// No-config builds fall back to "./css".
cssDir: "./src/css",
// CSS files scanned for @swatchkit token blocks (supports a trailing * glob).
// Default: ["<cssDir>/global/tokens.css", "<cssDir>/tokens.css", "<cssDir>/tokens/*.css"].
// Add theme files explicitly when needed. See docs/tokens.md.
tokenSources: ["./src/css/global/tokens.css", "./src/css/theme.css"],
// Copy cssDir into outDir/css. Set false to reference CSS via cssPath
// instead. See "Two ways to ship CSS" above.
cssCopy: true,
// Path from swatchkit's HTML to the CSS. Only used when cssCopy is false.
// Default: "../<basename of cssDir>/"
cssPath: "../css/",
// Exclude files from the pattern library (supports globs).
exclude: ["*.test.js"],
// Control sidebar section order and swatch order inside each section.
// Order lists are partial: listed slugs come first; unlisted items follow
// alphabetically. Ordering runs after exclude and tokenDocs filters.
order: {
sections: ["tokens", "components", "compositions", "utilities", "patterns"],
swatches: {
tokens: ["aries-brand-colors", "colors", "fonts"],
components: ["button", "card"],
},
},
// Customize generated token documentation. Utilities still generate from
// parsed tokens even when a token doc page is hidden.
tokenDocs: {
showSource: false, // default for generated token docs
colors: {
columns: ["name", "value", "customProperty"],
columnLabels: { customProperty: "CSS variable" },
includeLabels: ["Brand Colors"],
// excludeLabels: ["Internal Colors"],
},
spacing: { enabled: false },
},
// Override the default HTML renderers.
// renderSidebarSection: ({ category, categorySlug, items }) => string,
// renderSwatchSection: ({ slug, name, category, categorySlug, description, previewHref, content, escapedContent, sourceKind, showSource }) => string,
};SwatchKit looks for the config in this order: swatchkit.config.cjs (CJS), swatchkit.config.mjs (ESM), swatchkit.config.js (depends on project's package.json#type). Rename to .cjs if you need CJS syntax in an ESM project.
Ordering sections and swatches
Use order when you want important sections or swatches to appear first in the sidebar. Entries are slugs: section folder names such as components or compositions, tokens for generated token docs, and patterns for root-level swatches.
order is not an include list. It only sorts items that still exist after exclude and tokenDocs filters run. Unknown slugs are ignored, and wildcards are not supported in order lists.
When an order list is configured, listed slugs come first in the given order. Any unlisted items follow alphabetically.
CLI
swatchkit [command] [options]| Command | What it does |
| :--- | :--- |
| swatchkit init | Create swatchkit.config.js and scaffold CSS blueprints, layout templates, and a starter tokens.css. Prompts for cssDir; pass --cssDir to skip the prompt. Status report if already initialized. |
| swatchkit init --app | Also scaffold an integrated esbuild app starter (build scripts, shared renderers, home page, two example swatches, watch-enabled package.json). |
| swatchkit init --force | Overwrite all managed files (with .bak backups). |
| swatchkit init --dry-run | Show what would change, write nothing. |
| swatchkit (default) | Build the pattern library. |
| Flag | Short | What it does |
| :--- | :--- | :--- |
| --app | | With init: scaffold the integrated esbuild app starter. |
| --watch | -w | Rebuild on file change. |
| --config | -c | Path to config file. |
| --input | -i | Pattern source dir (default: swatchkit/). |
| --outDir | -o | Output dir (default: dist/swatchkit). |
| --cssDir | | CSS dir for init (default prompt: src/css). |
| --force | -f | Overwrite config + blueprints (with backups). |
| --dry-run | | init: report without writing. |
| --help | -h | |
| --version | -v | |
How it works
swatchkit (the build command) does four things:
- Parses
@swatchkittoken blocks from the CSS files intokenSources(your hand-written tokens — the source of truth). It regeneratescss/utilities/utilities.css(utility classes derived from those tokens) and the token-documentation HTML underswatchkit/tokens/. Your token CSS is never modified — onlyutilities.cssis generated. See docs/tokens.md. - Copies CSS from
cssDirtooutDir/css(only whencssCopy: true). - Scans
swatchkit/for swatches, renders each one. Staticindex.htmlswatches go through unchanged. Dynamicindex.jsswatches are imported and executed for their HTML. Sibling assets are copied alongside. - Writes
outDir/index.html(the library) and oneoutDir/preview/{section}/{id}/index.htmlper swatch (full-screen previews).
In watch mode, SwatchKit compares generated content against existing files and skips the write when nothing has changed — so most rebuilds don't touch your CSS directory, and there's no infinite-rebuild loop when running alongside onchange or framework dev servers.
What swatchkit owns vs what you own
| Path | Owned by | Notes |
| :--- | :--- | :--- |
| src/css/global/tokens.css | you | Source of truth for design tokens. Hand-edit freely (@swatchkit blocks). |
| src/css/main.css | you | Your entry point. |
| src/css/global/variables.css | you | Edit var() references if you rename tokens. |
| src/css/global/elements.css | you | Same. |
| src/css/utilities/utilities.css | swatchkit | Generated every build from your token blocks. Do not edit. |
| src/css/swatchkit-ui.css | you | Docs UI styling. Safe to customize. |
| src/css/swatchkit-preview.css | you | Preview page styling. Safe to customize. |
| swatchkit/_swatchkit.html | you | Layout template. init --force overwrites it. |
| swatchkit/_preview.html | you | Preview template. Same caveat. |
| swatchkit/tokens/*.html | swatchkit | Regenerated every build. |
| swatchkit/swatches/**/index.html | you | Your swatches. |
| swatchkit/swatches/**/index.js | you | Your JS swatches. |
| swatchkit/swatches/**/description.html | you | Optional, shown above the iframe in the library. |
| swatchkit/swatches/**/* (other files) | you | Copied as sibling assets. |
Using with a framework
SwatchKit only ever writes inside its own output directory — never the rest of dist/. If your framework (Vite, Astro, etc.) cleans dist/ on build, run it first and swatchkit after:
"scripts": {
"build": "vite build && swatchkit"
}In watch mode, SwatchKit polls for its output directory and rebuilds if it was wiped by an external tool.
Design tokens
SwatchKit is CSS-first: tokens live in plain CSS that you own and hand-edit.
You mark groups with @swatchkit comment blocks, and SwatchKit reads them to
document each group and to generate utility classes.
/* src/css/global/tokens.css */
:root {
/* @swatchkit colors "Brand Colors" */
--brand: #3b49df;
--brand-hover: oklch(from var(--brand) calc(l + 0.06) c h);
/* @swatchkit end */
}This documents a "Brand Colors" page and generates .color:brand,
.background-color:brand, etc. into css/utilities/utilities.css. Relational
values (oklch(from …), var(…), clamp(…)) are preserved verbatim in the
docs, so relationships are never flattened away.
Generated token docs hide their View source details by default. Use
tokenDocs to re-enable source, disable specific token doc pages, include or
exclude token labels, or customize color-table columns without removing the
@swatchkit markers from your CSS.
Supported token types: colors, spacing, text-sizes, text-weights,
text-leading, fonts, viewports. Blocks can live under any selector (and
inside @layer / @media), so theme variants are documented by wrapping each
theme's region in its own block.
Fluid type and spacing use plain, universally-supported clamp() (no build-time
generator), tweakable via --vw-min / --vw-max / --root-base config variables.
See docs/tokens.md for the full reference: marker syntax,
tokenSources, every type's utilities, and the fluid clamp() pattern.
Cascade layers
The scaffolded main.css declares a cascade-layer order so the cascade is predictable — a later layer always wins over an earlier one, regardless of selector specificity:
@layer reset, tokens, elements, compositions, swatches, app, utilities;| Layer | Contains |
| :--- | :--- |
| reset | browser reset |
| tokens | design tokens + variables |
| elements | bare element defaults |
| compositions | layout primitives (flow, cluster, sidebar, …) |
| swatches | your component styles |
| app | app/page-specific CSS (overrides components) |
| utilities | generated token utilities — the final word |
utilities is declared last, so utility classes win over component and app styles without !important. Add a utility class in your markup and it takes effect. Plain unlayered CSS still beats every layer — that's your escape hatch for the rare override.
Acknowledgements
The CSS compositions included by default are adapted from Every Layout by Heydon Pickering and Andy Bell.
