npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

knobkit

v0.3.0

Published

Create TypeScript webapps in minutes. Ship, host and share everywhere — declare widgets, write event handlers, done. The same file runs in the browser or on a stateless Node server.

Readme

knobkit

CI npm version license

Create TypeScript webapps in minutes. Ship, host and share everywhere. Declare widgets, write on(event, handler) functions — done. The same demo.tsx runs entirely in the browser (mount) or on a stateless Node server (serve); change the last line to swap. The browser owns all state — the server holds none of your app's data, so restarts and reconnects are free.

knobkit.dev — 30-second tour + a live playground (nothing to install).

🛠️ Building with an AI agent? The knobkit-skills Agent Skill is the recommended way to scaffold and build a knobkit app fast — works in Claude Code or any Agent Skills–compatible agent.

import { knobkit, mic, output } from "knobkit";
import { pipeline } from "@huggingface/transformers";

const transcriber = await pipeline("automatic-speech-recognition", "onnx-community/whisper-base.en");
const recorder = mic();
const transcript = output();

const app = knobkit({ title: "Transcribe", widgets: [recorder, transcript] });

app.on(recorder.clip, async (samples) => {
  const { text } = (await transcriber(samples)) as { text: string };
  transcript.set(text.trim() || "(silence)");
});

app.serve(); // runs Whisper on Node — change to app.mount("#root") to run it in the browser via WebGPU

See examples/ — chatbots, image captioning, live transcription, webcam filters, an agent dashboard, a drive-style file browser, an Electron desktop app; each a single demo.tsx.

Quick start

npm create knobkit@latest my-app   # prompts mount (browser) vs serve (node); or pass --mount / --serve
cd my-app && npm install && npm run dev

Already have a project? npm install knobkit. Requires Node ≥ 22.

CLI

knobkit dev         # dev server — auto-detects the tier from mount()/serve() in the entry
knobkit build       # build a mount app to static files in dist/
knobkit serve       # run a serve app
knobkit playground  # split-pane REPL: editor + live preview, file picker, edits round-trip to disk

Entry = your package.json "main" (override with knobkit dev other.tsx). --mount / --serve force the tier; --port <n> sets the port (playground default 4317).

How it works

A handler is a plain on(event, async fn). Inside it you do exactly three things:

  • read widget state with async getters — await box.value(), await convo.history() (a real round-trip on serve);
  • write with structured setters — out.set(v), convo.say(m), logw.push(line);
  • produce by returning an event from the handler (re-emitted, like a user action).

Streams (mic clips, webcam frames) arrive on channels, handled with the same on(...); the latest policy drops stale frames automatically while a handler is busy — no hand-rolled guards.

setup(fn) runs once per session for async startup (load weights, fetch data). widget.busy(fn) wraps a handler in a transient working span (a bar; drops the widget's input while running); disable() / enable() is the persistent version. Widget methods only work inside a handler or setup.

| | mount("#root") | serve() | |---|---|---| | on(...) handlers | run in the browser | run on a stateless Node server | | transport | local call | WebSocket (auto-resume on reconnect) | | use when | fits client-side (incl. WebGPU models) | needs the server (large models, secrets, native deps) |

mount builds to static files you can host anywhere; serve keeps no app state on the server. Widgets, handlers, and methods are identical across both — only the last line changes.

Binary data (images, audio) travels as a MediaRef — an opaque handle you get from toMedia(bytes, mime) and turn back with await mediaBytes(ref) or mediaUrl(ref); the bytes themselves stay out of app state and cross the wire lazily. knobkit/media adds dataUrlToBytes, bytesToDataUrl, pcmToWav.

Widgets

Value inputs share one shape: a changed event whose payload is the value, plus await w.value() and w.set(v).

| Factory (defaults) | changed value | Notes | |---|---|---| | text({ placeholder?, lines? }) | string | lines = textarea rows (default 1) | | number({ value?, min?, max?, step? }) | number | numeric stepper (init 0) | | dropdown({ choices, value? }) | string | choices: (string \| { value, label? })[]; value defaults to the first choice | | slider({ value?, min?, max?, step? }) | number | min 0, max 100, step 1; value defaults to min | | checkbox({ label?, value? }) | boolean | single toggle | | checkboxGroup({ choices, value? }) | string[] | multi-select | | radio({ choices, value? }) | string | single-select; value defaults to the first choice |

Other inputs:

| Factory (defaults) | Events / channels | Methods | |---|---|---| | button({ label }) | clicked | | | upload({ accept?, multiple?, label? }) | picked (MediaRef, per file), changed (UploadFile[]) | await value(), await files(), clear(). UploadFile: { name, type, size, ref } | | mic({ every?, control?, hold? }) | clip channel (Float32Array, 16 kHz mono PCM) | start(), stop(), await toggle(), await live(). every ms emits a clip every N ms (0 = one clip per recording) | | webcam({ every?, preview? }) | frame channel (MediaRef, JPEG) | start(), stop(). every ms emits a frame every N ms (0 = preview only) | | chat({ placeholder?, voice?, images?, markdown? }) | sent ({ text, image? }), recorded channel (Float32Array) | await history(), say(msg), append(token), clear(). markdown renders assistant replies; images/voice add attach/talk buttons |

Outputs (write-only):

| Factory (defaults) | Write / methods | Notes | |---|---|---| | output({ format? }) | set(text), append(text), clear() | format: "markdown" renders GFM; append streams tokens | | log({ maxLines? }) | push(line), pushStyled(line, level?), setFilter(q), clear(), await all() | append-only lines; levels color-code | | json() | set(value) | pretty-printed JSON | | label() | set(string \| { label?, confidences? }) | classifier result; confidences: { label, score }[] → bars | | html({ value? }) | set(markup) | raw HTML | | progress({ label? }) | set(value, label?) | value is 0..1 | | file() | set({ name?, url } \| url) | offer a download; url may be a MediaRef | | image() | show(srcOrRef), clear() | MediaRef or URL | | gallery() | set(items), add(item) | item: { src, caption? }; src MediaRef or URL | | audio({ autoplay? }) | set(srcOrRef) | MediaRef or URL | | video({ autoplay?, loop? }) | set(srcOrRef) | MediaRef or URL | | annotatedImage() | set(src, annotations?, colorMap?) | Annotation: { label, box?: [x0,y0,x1,y1], mask? } | | highlightedText() | set(spans, colorMap?) | span: { text, label? } (label omitted = plain) | | frame({ src? }) | set(url) | iframe | | chart({ x, y, kind?, data? }) | setData(rows) | x = category key; y = key or string[]; kind bar/line/area | | diff() | setFiles(files) | FileDiff: { path, oldContent, newContent, language?, status? } | | statusBadge(status?, { variants? }) | set(status) | dot + label; variants maps custom statuses to idle/running/waiting/completed/failed/error | | toast() | show(message, variant?) | transient notifications; variant info/success/warning/error |

Editable or read-only:

| Factory (defaults) | Events | Methods | |---|---|---| | code({ value?, language?, readOnly? }) | changed (string) | await value(), set(src), setLanguage(lang) | | table({ columns?, rows?, editable?, maxHeight? }) | edited ({ row, key, value }), contextmenu ({ item, row, x, y }) | await data(), setRows, setColumns, addRow, setCell. Column: { key, label?, type?, width? } | | terminal({ rows?, cols?, scrollback?, echo? }) | data (string), resized | write(text), writeln(line), clear(). echo echoes typed input locally |

Navigation:

| Factory (defaults) | Events | Methods | |---|---|---| | toolbar(items?) | clicked ({ id }) | setItems(items). ToolbarItem: { id, label, icon?, disabled?, variant?, separator? } | | tree(nodes \| { nodes?, expanded?, selected? }) | selected/activated ({ id, data? }), expanded/collapsed, contextmenu ({ id, x, y }), renamed ({ id, name }) | setNodes, setChildren(id, nodes), expand(id), collapse(id), select(id), rename(id). TreeNode: { id, label, icon?, children?, hasChildren?, data? } | | breadcrumb({ crumbs? }) | selected ({ id }) | set(crumbs). Crumb: { id, label } | | menu() | selected ({ action, target }) | open({ x, y, items, target? }), close(). MenuItem: { id, label, icon?, danger?, disabled?, separator? } | | sidebar(sections?) | selected ({ id }) | setSections(sections). Section: { label, items: { id, label, icon?, badge? }[] } |

Layout

widgets is a tree of widget objects (no keys/strings). An array is an implicit col:

knobkit({ widgets: col(photo, row(size, go), caption) });
grid([a, b, c, d], { cols: 2 });
tabs([{ label: "One", content: a }, { label: "Two", content: b }]);
splitPane(editor, preview, { direction: "horizontal", ratio: 0.5 });
drawer(nav, main, { open: true });
accordion({ label: "Advanced", open: false }, x, y);

Containers are widgets whose state is their arrangement, so a handler can restructure the UI at runtime — panel.add(chart), await panel.removeChild(chart).

Slot modifiers tune one child in place: span(w, 2) claims extra grid/row slots, grow(w) absorbs a col's leftover space, and density(w, "sm") / theme(w, "dark") restyle just that subtree.

Build your own widget

Widgets aren't privileged — every built-in is written against the same public API. A widget is one defineWidget call plus a React view:

// counter/def.ts
import { defineWidget, t, viewRef } from "knobkit";

export const counter = defineWidget({
  type: "counter",
  state: { count: { initial: 0 } },                // runtime-changing attrs
  props: { step: { default: 1 } },                 // static config
  events: { changed: { payload: t<number>() } },   // or a Standard Schema for real validation
  ops: (at) => ({ add: at("count").op("inc"), reset: at("count").op("set", 0) }),
  methods: (self) => ({ value: () => self.at("count").get() }),
  view: viewRef(import.meta.url, "./view.js"),
});
// counter/view.tsx
import type { ViewProps } from "knobkit";

export default function CounterView({ props, state, emit }: ViewProps<{ count: number }, { step: number }>) {
  return <button onClick={() => emit("changed", state.count + props.step)}>{state.count}</button>;
}

Drop it straight into your app (widgets: [counter(), …]), or publish it: a widget package depends only on @knobkit/core (the engine kernel — as a peer dependency, no widget-library baggage), namespaces its type "<pkg>/<name>", and gets exactly the powers of the built-ins. Views load as lazy chunks, so heavy dependencies cost nothing until rendered. See CLAUDE.md for the full recipe.

Theming

Set on knobkit({ … }), or flip at runtime with setTheme / setDensity:

  • theme"system" (default) | "light" | "dark".
  • density"xs" | "sm" | "md" | "lg" | "xl" (default md) — spacing, control sizes, radii, type.
  • fill: true — full-bleed shell that fills the viewport (for split panes / dashboards) instead of the centered card.

Everything renders from CSS custom properties (--pu-bg, --pu-accent, --pu-gap, the --pu-series-* chart palette, …); theme/density just remap them, so one switch restyles the whole kit (including the code editor, table, and chart). The attributes inherit, so you can scope them to one container; to rebrand, override the tokens in your CSS (e.g. :root { --pu-accent: rebeccapurple }).

Develop

Monorepo: packages/core is the engine (@knobkit/core), packages/knobkit the widget kit + CLI.

pnpm install
pnpm build            # core, then knobkit (library + browser bundle)
pnpm test             # vitest, both packages
pnpm typecheck        # all packages incl. examples

See CLAUDE.md for the architecture and how to add a widget.

License

MIT