@sikka/mugshot
v0.3.1
Published
Screenshot any rendered React component into marketing-ready PNGs.
Downloads
12,252
Readme
@sikka/mugshot
Screenshot any rendered React component into marketing-ready PNGs — as it
is: typed values, open dropdowns, arrangement. A literal mugshot, not a
re-render. Your components render untouched: no wrapper requirements
beyond <Mugshot>, no portal props, no edits to your inputs — popovers,
select menus, and calendars from any UI library are captured with their
trigger at the right scale.
import { Mugshot } from "@sikka/mugshot";
<Mugshot shotId="upload">
<YourFileUploader /> {/* the raw component — no cards, no copy */}
</Mugshot>;mugshot # all shots → public/mugshots/<id>.png
mugshot upload --scale 1import { MugshotCard } from "@sikka/mugshot";
<MugshotCard imageSrc="/mugshots/upload.png" tagline="Drag, drop, done." />;Install
pnpm add @sikka/mugshotreact >= 18 is the only peer dependency. MugshotStudio needs no Tailwind, no CSS, no config — it is inline-styled and works in any Next.js app (App or Pages Router, app/ or src/app/). MugshotCard and the optional Toolcraft chrome use Tailwind but are never required for capture. Capture runs headless Chromium via playwright-core — no browser config, no canvas libraries. (Excalidraw / tldraw were deliberately NOT used: they are vector-drawing canvases that can't host live DOM and would add megabytes — the pan/zoom is a ~60-line view transform, captures always at true scale.)
pnpm exec playwright-core install chromium --only-shell # once per machineIntegrate (any Next.js project, 60 seconds)
No Tailwind, no config, no CSS import. Works with app/ or src/app/, App Router or Pages Router.
pnpm add @sikka/mugshot
pnpm mugshot init # scaffolds API route + studio route
pnpm exec playwright-core install chromium --only-shell # once per machine
pnpm dev # open http://localhost:3000/dev/shotsmugshot init creates:
app/api/dev/mugshot/route.ts—createMugshotHandlers()(snapshot store + capture runner; dev-guarded, never serves prod)app/dev/shots/page.tsx—MugshotStudiostarter with aDemoScene— replace with your real componentspublic/mugshots/.gitkeep
Or scaffold a custom route: pnpm mugshot init --route /dev/mugshots
Manual (2 files, same result):
// app/dev/shots/page.tsx
"use client";
import { MugshotStudio } from "@sikka/mugshot";
import { UploadScene } from "./scenes";
const SHOTS = [{ id: "upload", label: "Upload", render: UploadScene }];
export default function Page() {
if (process.env.NODE_ENV !== "development") return null;
return <MugshotStudio shots={SHOTS} />;
}// app/api/dev/mugshot/route.ts (App Router)
import { createMugshotHandlers } from "@sikka/mugshot/server";
export const { GET, POST } = createMugshotHandlers();
// Pages Router: pages/api/dev/mugshot.ts → export default createMugshotPagesHandler()Scenes must be deterministic (pinned dates, seeded values) so re-captures are stable — but anything interactive (typed text, open menus, toggles) is captured live, as-is. Scene function identities must stay stable across renders (define SHOTS at module level).
Capture — arrange freely on the infinite canvas, then press Save: a photo frame appears — drag it to reposition, pull its handles to resize — then Take photo captures exactly what's inside the box, as-is (headless @2x). Or headless:
mugshot upload --base-url http://localhost:3000 --route /dev/shotsPNGs land in public/mugshots/ — use <MugshotCard> or your own markup. Guard the studio route with NODE_ENV !== "development" → 404 (handlers already do).
Connect (remote studios)
Any Next.js project can expose its scenes to a remote studio (a Toolcraft app, a dashboard, anything HTTP) — no component source ever leaves the app:
1. Config — only if you need Toolcraft chrome (optional for lightweight studio):
// next.config.ts — skip this for the default lightweight MugshotStudio
import { withMugshot } from "@sikka/mugshot/next";
export default withMugshot(nextConfig);2. Declare scene settings next to your scenes (keys map 1:1 to real component props):
// app/dev/shots/connect.ts
import type { MugshotConnectScene } from "@sikka/mugshot";
export const CONNECT_SCENES: MugshotConnectScene[] = [
{
id: "currency-input",
label: "Currency input",
route: "/dev/shots",
settings: [
{ kind: "boolean", key: "commas", label: "Show commas", defaultValue: true },
{ kind: "boolean", key: "symbol", label: "Show symbol", defaultValue: true },
{ kind: "text", key: "amount", label: "Amount", defaultValue: "12000" },
],
},
];Setting kinds: select (options + default), boolean, text
(default + placeholder), number (default + min/max/step).
3. Read initial values in scenes (stays fully interactive locally):
import { resolveMugshotSettingValues } from "@sikka/mugshot";
const [initial] = React.useState(() =>
resolveMugshotSettingValues(
typeof window === "undefined" ? "" : window.location.search,
CURRENCY_SETTINGS,
),
);
// initial.commas / initial.symbol / initial.amount — validated, defaulted4. Support the transparent embed stage on your studio route
(?shot=<id>&mss=<encoded>&mugshot-embed=1):
import { isMugshotEmbedRequest } from "@sikka/mugshot";
if (typeof window !== "undefined" && isMugshotEmbedRequest(window.location.search)) {
const id = new URLSearchParams(window.location.search).get("shot");
if (id && id in SCENES) return <div style={{ background: "transparent" }}>{/* bare scene */}</div>;
}5. Serve the manifest from the API route:
// app/api/dev/mugshot/route.ts
import { createMugshotHandlers } from "@sikka/mugshot/server";
import { CONNECT_SCENES } from "../../dev/shots/connect";
export const { GET, POST } = createMugshotHandlers({ scenes: CONNECT_SCENES });Remote contract (all dev-guarded):
# list scenes + setting schemas
curl "http://localhost:3000/api/dev/mugshot?manifest=1"
# → { version: 1, shots: [{ id, label, route, settings }] }
# transparent PNG bytes for driven settings (no files written)
curl -X POST http://localhost:3000/api/dev/mugshot \
-H 'Content-Type: application/json' \
-d '{"preview":{"shot":"currency-input","route":"/dev/shots","query":"mugshot-embed=1&mss=<encoded>","scale":2}}'
# → { dataUrl: "data:image/png;base64,..." }Build shareable scene URLs with mugshotSceneUrl(route, shotId, values)
from @sikka/mugshot. Screenshots are element captures with
omitBackground, so empty areas stay transparent — the remote studio
chooses the backdrop.
Studio
<MugshotStudio shots={…} /> is zero-dependency (React only, all styles inline) — no Tailwind, no shadcn, no CSS import needed. It works in any Next.js app, any design system, any CSS setup.
It handles the whole job:
- infinite canvas (full-screen, sidebar controls): add scenes, drag them anywhere with optional 10px snap, per-item scale, remove — arrangements + photo frame persisted per shot in localStorage;
- pan/zoom navigation (background drag, hold Space, middle-mouse, scroll to pan, Ctrl+scroll to zoom, Fit button) — pan/zoom is view-only, captures always happen at true scale;
- photo frame on Save: drag to reposition, pull handles to resize (or type W×H, or Fit-to-content) — Take photo captures exactly what's inside;
- white / dark / transparent canvas backgrounds;
- two export paths: instant in-browser
Export(HTML-in-canvas, see below) where the browser supports it, plus headless photo capture (@2xvia the CLI) that works everywhere — both save intopublic/mugshots/with a preview.
Props: canvasSize (default 600 — default frame size + capture fallback), exportSize
(default 2048 — native-export long edge), storageKey, endpoint
(default /api/dev/mugshot), resolveOutName, previewBase (default
/mugshots), targetSuffix (default -capture — the capture box target
[data-mugshot="<id>-capture"] must differ from scene ids, since snapshots
keep inner <Mugshot> targets verbatim).
Hotkey — capturing open menus (required, not optional): open any dropdown/popover/calendar, keep it open, then press ⌘⇧C (Ctrl+Shift+C on Windows) for Capture or ⌘⇧E for Export. Clicking Take photo can never capture an open menu: floating-UI libraries (Base UI, Radix) cover the page with an invisible dismiss layer the moment a menu opens, so the click lands on that layer and closes the menu before any handler runs. The hotkey serializes the live frame on keydown while the menu is still mounted — no pointer involved — so the menu is cloned glued to its trigger at any item scale. Scene controls stay clickable at all times (items drag only from empty areas; the background pans), so you can always open the menu first, then hotkey.
Next.js helper (optional)
withMugshot merges transpilePackages: ["@sikka/mugshot"] so the package
compiles from source; your own config always wins on conflicts:
// next.config.ts
import { withMugshot } from "@sikka/mugshot/next";
export default withMugshot(nextConfig);Default MugshotStudio needs no config — it uses only React + inline styles
and works in any Next.js app out of the box.
How literal capture works
Two paths, same literal-mugshot principle (never re-mount scenes):
Export — instant, in-browser (shown when supportsNativeCapture()
passes, i.e. Chrome with chrome://flags/#canvas-draw-element). The live
stage nodes move into a hidden layoutsubtree canvas and rasterize via a
single drawElementImage(content, 0, 0, 2048, 2048) at full export
resolution, then move back — typed values, canvases, and open menus travel
with full state. This is the
canvas-ui pattern
(layoutsubtree + requestPaint → paint + drawElementImage, same
support probe, failures fall back to headless capture).
Capture — headless (works in any browser). Instead it:
- serializes exactly the live frame region (
serializeMugshotRegion) — intersecting items re-based to frame origin with input values, checkboxes, selects, textareas, canvases (signature pads) baked in; open floating menus cloned in place at their trigger's measured scale (works for any portal-based library — Radix, Base UI, Headless UI…); - stores the HTML via the API route, then runs the CLI against
?mugshot-capture=1&snapshot=…&bw=…&bh=…, which renders that HTML verbatim in a frame-sized box (same app CSS, pinned theme) and screenshots exactly that box with animations frozen — a literal crop of the blue frame, so anything overhanging it (menus included) crops at the edge; resize the frame to include what you want.
Snapshots expire after 30 minutes and never touch your repo. Menus that can't be attributed to a canvas item (e.g. your own dev toolbar's popovers) are excluded; viewport-scale modal backdrops are skipped.
The paradigm, stated plainly: the model is always scale 1 and portals always natural — everything renders exactly like normal DOM, so any HTML/React just appears. Pan/zoom are view-only (frame freely, even mid-zoom); arrangement is plain world left/top; anything outside the frame simply crops out of the shot. Per-item scale is there for emphasis, and open menus are re-scaled to their trigger's item scale in the snapshot — dropdowns capture glued at any scale.
Classic registry flow (unchanged)
Deterministic CI-style captures without the studio: register shots
([{ id, label, route, imageSrc }]), render <Mugshot shotId> scenes on a
route that reads ?shot=<id>, and run the bin. --overlays adds the
union-box pass for scenes that mount already-open (<Select defaultOpen>).
CLI
mugshot init [--route /dev/shots] # scaffold any Next.js app (App or Pages Router)
mugshot --help
mugshot [shot …] [--base-url …] [--out-dir …] [--scale 2] [--registry …] [--query …] [--target-suffix …] [--route …] [--out-name …] [--overlays]| Flag | Default | Meaning |
| ---------------- | --------------------- | --------------------------------------------------------------- |
| shot … | all registered | Capture only these ids |
| --base-url | http://localhost:3000 | Dev server origin (must be running) |
| --out-dir | <cwd>/public/mugshots | Where <id>.png files are written |
| --scale | 2 | Retina multiplier (deviceScaleFactor) |
| --registry | packaged registry | JSON file with your shots array |
| --query | — | Extra query appended to each shot URL (e.g. square=1&x=0&y=0) |
| --target-suffix | — | Suffix for the capture selector ([data-mugshot="<id><suffix>"]) |
| --route | registry route | Override the route for this run (e.g. a locale twin) |
| --out-name | <id>.png | Override the output filename (single-shot runs only) |
| --overlays | off | Union-box capture incl. open floating UI (menus, popovers) |
--query + --target-suffix pair up to shoot alternate compositions.
--route + --out-name pair up for locale twins. Each shot is captured
from <route>?shot=<id> — if your page shows one scene at a time, read the
shot search param and mount only that scene (in Next.js, useSearchParams
needs a <Suspense> boundary). Studio snapshot runs need no registry file:
with an empty registry, explicit shot ids + --route synthesize entries.
Programmatic use:
import { captureMugshots } from "@sikka/mugshot/cli";
await captureMugshots({
baseUrl: "http://localhost:3000",
registry: [{ id: "upload", label: "Upload", route: "/dev/shots", imageSrc: "/mugshots/upload.png" }],
shots: ["upload"],
});Chromium
Resolution order: $MUGSHOT_CHROMIUM_PATH → shared ms-playwright cache
(newest headless shell first, then full Chromium; win/mac/linux) → Playwright
default (errors with install instructions). No download needed if the machine
already has Playwright browsers. Keep playwright-core in step with the
browser build — version skew causes protocol errors on launch.
Entries
@sikka/mugshot— browser-safe:Mugshot,MugshotCard,MugshotStudio(lightweight, zero CSS),serializeMugshotStage,serializeMugshotRegion(+collectMugshotRegionItems/Overlays),mugshotOverlayScale,captureStageNative,captureRegionNative,supportsNativeCapture,MUGSHOT_EXPORT_SIZE,MUGSHOT_SHOTS(+ types). Never pulls in Node APIs orplaywright-core.@sikka/mugshot/cli— Node-only:captureMugshots(+ types). Never import from client components. Includesmugshot initscaffolder.@sikka/mugshot/server— Node-only Next.js route handlers:createMugshotHandlers(App Router),createMugshotPagesHandler(Pages Router) (+ types). Never import from client components.@sikka/mugshot/next— optionalwithMugshot()helper (only for Toolcraft premium chrome).
License
Proprietary — Sikka Software.
