Pick a color, drag the roundness, retype the whole system, and watch a real product surface re-skin under your hands: an app bar, stat cards, a form with fifteen controls, a sortable table, overlays, alerts, a calendar. When it looks right, take it with you as CSS, as a prompt for your coding agent, as JSON, or as a link.
| Group | Knobs |
|---|---|
| Presets | The palettes @manti-ui/tokens ships (Manti, Violet, Ocean, Forest, Rose, Graphite): colors and the neutral hue, nothing else |
| Accent | A grid of base colors for the primary variant, plus a picker for anything else; the other five variants sit one disclosure below |
| Neutral | Eight named hues, each driving every gray, surface, border and text role; the generated ramp and the light/dark switch sit beside them |
| Radius | Five stops (None to Full), each rescaling the whole xs to 2xl ramp |
| Typography | Sans font stack, and the base text size (14 to 18) every other size derives from |
| Density | Scaling from 90% to 110% over the spacing unit and control heights, plus focus-ring width |
Not sure where to start? Surprise me rolls a coherent random theme.
The Copy theme dialog hands the result over four ways:
- CSS tokens. A stylesheet you drop into your app, with the import order spelled out in its header.
- AI prompt. A brief for Claude Code, Cursor or any agent working in your repo: where to put the file, why it must stay unlayered, and how Manti's token tiers relate.
- JSON. The raw studio state, for storing themes in your own tooling.
- Share link. The whole theme base64url-encoded into
?t=, so a URL reopens the studio exactly as you left it. No account, no server.
Every knob writes a public token override, the same escape hatch a consumer
uses. The studio collects them into one unlayered <style> element in
<head>, which beats Manti's layered @layer manti.tokens defaults without a
single !important.
That is why the CSS in the export dialog is byte-for-byte the CSS the page you are looking at is running: one builder feeds both. Nothing here is studio-only. If the preview looks right, pasting the exported CSS into your app gives you the same result.
/* what a themed app ends up importing */
@import '@manti-ui/styles/index.css';
@import './manti-theme.css'; /* the studio's output, unlayered, last */Node >=22.12.0, pnpm 10.
pnpm install
pnpm dev # http://localhost:5173
pnpm verify # lint + typecheck + production build
pnpm preview # serve the built dist/ on :4173| Path | What lives there |
|---|---|
src/theme/customTheme.ts |
The theme store: preset selection, custom token CSS, persistence, URL sharing |
src/theme/useTheme.ts |
Light/dark, driven by data-theme on <html> |
src/studio/ThemeControls.tsx |
The control rail: presets, accent, neutral, radius, type, density |
src/studio/ThemePreview.tsx |
The preview canvas, a product-shaped tour of the component set |
src/studio/ExportDialog.tsx |
CSS / AI prompt / JSON / share-link hand-off |
src/styles/studio.css |
App chrome, in its own @layer studio |
Adding a knob touches two places: ThemeConfig, its stop list, and
buildThemeCss() in customTheme.ts, which is also what gets exported, and a
control in ThemeControls.tsx. Structural knobs are named stops rather than
free ranges, so the presets and Surprise me always land on a value the rail can
show as selected. The preview needs no change; it reads tokens like every
other Manti consumer.
The app depends on the published @manti-ui/* packages rather than a
workspace link, so it exercises the design system the way a third party does. To
try an unreleased library change, pnpm link the package from the
manti-ui/ui checkout.
Preset base themes are imported from @manti-ui/styles/themes.css and selected
with data-manti-theme. The studio only injects unlayered overrides for values
changed after a preset is selected.
Netlify, from netlify.toml: pnpm build into dist/, a www to apex 301,
and a CSP that allows the preview's avatar portraits (i.pravatar.cc).
There is deliberately no SPA catch-all. The app has one route and a shared
theme rides in the query string, so /?t=... already resolves index.html as a
static file. A /* rewrite would answer every typo with a 200 and the full
studio, which reads to a crawler as unlimited URLs serving identical content;
unmatched paths get 404.html and a real 404 instead. Bring the rewrite back the
day this app grows a router.
MIT, same as Manti UI.