A themeable design system built on StyleX. One set of components, one token contract, and as many installable themes as you care to write — each a separate package that gives every token a value, and does nothing else.
Live demo — every component, in all three themes, in light and dark.
pnpm add @batik-prototype/core @batik-prototype/theme-ocean @stylexjs/styleximport { Badge, Button, Card, Input, ThemeProvider } from '@batik-prototype/core';
import { ocean } from '@batik-prototype/theme-ocean';
<ThemeProvider theme={ocean} colorScheme="system">
<Card>
<Badge tone="success">Ready</Badge>
<Input placeholder="Search" />
<Button>Save</Button>
</Card>
</ThemeProvider>;| Package | What it is |
|---|---|
@batik-prototype/core |
Components, the token contract, and the theme runtime |
@batik-prototype/theme-classic |
Slate and blue, small rounded corners — the one to start from |
@batik-prototype/theme-ocean |
Near-monochrome slate, one teal accent, fully rounded |
@batik-prototype/theme-sunset |
Warm greys, one terracotta accent, square corners, serif |
@batik-prototype/example |
React + Vite app that consumes all of the above |
@batik-prototype/config |
Shared TypeScript and Vite+ configuration (internal) |
@batik-prototype/create-package |
The generator behind vp create package (internal) |
@batik-prototype/create-theme |
The generator behind vp create theme (internal) |
themes/ is the guide to writing a theme of your own.
@batik-prototype/core declares every colour, radius, font and spacing step as a StyleX
variable, unset by default. Components read only those variables. A theme package is
plain data: a value for every one of them, handed to defineTheme(), and <ThemeProvider>
sets those values on a subtree.
Three consequences are worth stating up front:
- The default is unstyled, and every theme answers to one contract. An app with no theme renders its components truly unstyled. A theme has to set every token core names, so adding one to core stops every theme compiling until it does — and a theme package installed against a newer core says at load which tokens it is missing. See the contract.
- Components cost nothing at runtime. They read CSS variables, not context, so switching a theme re-renders one provider and repaints — it does not re-render the tree.
- Components are compiled with the app, not before it. Core ships its StyleX calls
uncompiled; your bundler's StyleX plugin reads them alongside your own source, and
defineTheme()reads back the variable names it settles on. See Setting up the compiler.
vp install # after every pull
vp check # format, lint, type check
vp check --fix # and fix what can be fixed
vp test run
vp run -r build # every package, plus the publish gates
pnpm dev # the example app, and watch builds for the packagesEvery change to a published package needs a changeset — CI fails a PR without one.
pnpm changesetThree workflows, all triggered by a push to main. Nothing is versioned, published or
deployed from anyone's machine.
| Workflow | Does |
|---|---|
ci.yml |
vp check, vp test run, vp run -r build, and the changeset gate |
release.yml |
Opens the version PR, then publishes to npm once it merges |
deploy.yml |
Builds the example app and deploys it to GitHub Pages |
The changesets action drives it, in two passes over the same workflow:
- A push to
mainwith changesets pending opens (or updates) achore(release): version packagesPR. That PR is wherechangeset versionruns — it bumps the versions, writes eachCHANGELOG.mdand deletes the changesets it consumed. - Merging that PR leaves no changesets, so the same workflow runs
pnpm run releaseinstead:vp run -r build— which is also what runs publint, attw and the unused-dependency gate — thenpnpm publish -r.
Publishing is pnpm rather than changeset publish because publishConfig.exports and the
workspace:^ rewrite are pnpm features; npm ignores both and would publish an exports
map still pointing at ./src/index.ts.
Two secrets make it work. NPM_TOKEN needs read and write on the @batik-prototype scope
and must not be 2FA-gated, since the publish is non-interactive. GITHUB_TOKEN is
supplied automatically. Provenance is signed with the workflow's OIDC token, which is why
the job asks for id-token: write.
Every push to main rebuilds the example app and publishes it to Pages. There is nothing
to switch on first: configure-pages enables Pages and points it at Actions on the first
run.
The build reads its base path from that same step rather than hard-coding one, so a rename
or a custom domain needs no edit. It also calls vp build directly instead of going
through the build task — a task result is cached on its tracked inputs, and a --base
flag is not one, so the task could replay a bundle built for / whose asset URLs would
all 404 under a project site.