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

@pebble-software/bonsai

v0.0.8

Published

[![npm version](https://img.shields.io/npm/v/%40pebble-software%2Fbonsai)](https://www.npmjs.com/package/@pebble-software/bonsai) [![status: preview](https://img.shields.io/badge/status-preview-orange)](https://github.com/PebbleSoftwareInc/pebble-bonsai)

Readme

@pebble-software/bonsai

npm version status: preview react

⚠️ Active development / preview — this package is pre-1.0 and evolving quickly. APIs, the configuration schema, and the component set may change between releases without a deprecation cycle. Pin an exact version and read release notes before upgrading. Feedback and issues are very welcome!

The React runtime that turns a validated Bonsai configuration into a fully interactive, styled experience. It's built for UI that has to be delivered remotely (email, SMS, embedded experiences) but rendered locally with real React components, consistent styling, and predictable data flow.

Bonsai is the rendering engine behind Pebble Bonsai, an open-source form and signature service — but the renderer is useful anywhere you need to ship UI as data: define a screen as a JSON template, bind it to your data with {{ }} expressions, and let one component render it on the server (SSR, PDF) or in the browser (fully interactive).

Table of contents

Features

  • Schema-driven UI — every screen is a Zod-validated Config tree, so the renderer only ever processes known-safe structures.
  • Universal rendering — one component (Bonsai) picks server (SSR) or client (interactive) rendering automatically based on environment, with web, pdf, and preview render modes.
  • Composable components — schema component identifiers map to real React implementations via a componentMap, so the same schema can target different component sets.
  • Isolated per-instance state — each mounted Bonsai tree owns its own valtio store, scoped via React context. Nothing about which data renders lives in shared or module-level state.
  • Fine-grained reactivity — every leaf node reads the store itself and evaluates only its own bindings, so editing one field re-renders just that field and whatever depends on it.
  • Structural nodeseach and if handle looping and conditional rendering directly in the schema, with two-way binding preserved inside loops.
  • Sandboxed expression language{{ }} bindings are evaluated by a small, fixed-surface formula language, never executable JavaScript.
  • Tailwind CSS v4 design system — a themeable token set (colors, typography, spacing, shadows) drives every component, and templates can layer on any Tailwind utility via className.

Installation

npm install @pebble-software/bonsai
# or
pnpm add @pebble-software/bonsai
# or
yarn add @pebble-software/bonsai

Peer dependencies:

  • react and react-dom 18 or 19
  • A bundler that understands ES modules (Vite, Next, Rspack, etc.)

Preview note: while the package is pre-1.0, pin an exact version (npm install @pebble-software/bonsai --save-exact) — minor releases may contain breaking changes.

Working in this monorepo

pnpm install
pnpm --filter @pebble-software/bonsai run build

The shared workspaces (@bonsai/constants, @bonsai/schemas) are aliased to their sources and bundled directly into dist/ by Vite, so downstream consumers only ever install this one package.

Quick start

import { Bonsai, type Config } from "@pebble-software/bonsai";

const template: Config = {
  type: "card",
  args: { className: "p-6 flex flex-col gap-4" },
  children: [
    {
      type: "label",
      args: { htmlFor: "name" },
      children: ["Enter your full name"],
    },
    {
      type: "input",
      args: { id: "name", type: "text", value: "{{profile.name}}" },
    },
    {
      type: "button",
      args: { variant: "animated" },
      children: ["Continue"],
    },
  ],
};

export function Example() {
  return (
    <Bonsai
      template={template}
      captureUrl="/v1/capture/some-form-id"
      expiry="2026-12-31T00:00:00Z"
      data={{ profile: { name: "Ada Lovelace" } }}
    />
  );
}

Bonsai renders server-side (a plain, single-pass render) when window is undefined, and client-side (an interactive, valtio-backed tree) otherwise — no extra setup required either way.

Package exports

| Export | Type | Description | | --- | --- | --- | | Bonsai | component | Universal entry point. Renders the SSR path on the server and the interactive client path in the browser. | | useUpdateData() | hook | Returns an updateData(path, value) function bound to the nearest Bonsai instance's store. Must be called from a component mounted inside it. | | componentMap | object | Maps schema component identifiers to React implementations. | | controlledInputMap | object | Defines how specific component types behave as controlled inputs (value prop, change handler, value extractor). | | Config | type | Runtime type describing a Bonsai node. | | SignatureProps | type | Props contract for the built-in signature capture component. |

Bonsai accepts:

interface BonsaiProps {
  template: Config;
  captureUrl: string; // POST target for the submit handler
  expiry: string;
  data?: any; // initial store data
  status?: "pending" | "captured" | "completed" | "error" | "revoked";
  render?: "web" | "pdf" | "preview";
}
  • render: "web" (default) mounts the tree inside a <form> whose submit handler POSTs the captured data to captureUrl. "preview" renders the same form UI flagged with data-mode="preview" for host styling. "pdf" renders a plain <div> wrapper with no form — the layout used for print/PDF output.
  • status other than "pending" disables capture: the submit handler no-ops instead of POSTing.

Each mounted instance owns its own store — there's no shared module-level state and no global updateData; useUpdateData() is the only way to reach a given instance's data, and only from within its subtree.

Configuration schema

The schema is defined in library/schemas/src/bonsai.schema.ts and exported as Config.

  • Node shape: each node is either a string literal (rendered directly, and itself eligible for {{ }} bindings) or an object with:
    • type: a value from COMPONENT (see Component reference), including the structural each and if types (see Structural nodes).
    • args: props for the resolved React component. All args values are eligible for dynamic bindings.
    • children: an optional array of nested Config nodes.
  • Validation: Config is a strict, discriminated union with a tailored prop contract per component (e.g. inputs define value, placeholder, etc.) — unknown props on a known component type are rejected, not silently dropped. Invalid configs fail fast during parsing.
  • Conditional rendering is a dedicated if node, not a generic condition prop on any component — see Structural nodes.

Example node:

{
  "type": "checkbox",
  "args": {
    "id": "terms",
    "checked": "{{agreements.terms}}",
    "className": "mt-4"
  },
  "children": ["I agree to the terms"]
}

Complete example templates live in the repo's examples/ directory, including a kitchen-sink template that exercises every component type.

Dynamic data binding

  • Use {{ }} to declare bindings. A binding must span the entire string value — "{{a}}" is evaluated, "{{a}} of {{b}}" is not (put each placeholder in its own child/string instead). Whitespace inside {{ }} is trimmed automatically.
  • Paths support dot notation (user.email) and array indexing (addresses[0].street).
  • Missing keys resolve to undefined; use the default(value, fallback) helper for a fallback inline, or handle defaults in host data.
  • Any args prop or string child can contain a binding.
  • Besides your own data, the instance's top-level state fields — status, expiry, and submitting — are readable from bindings too (e.g. an if on {{submitting}}).
  • Inside an each row, an identifier resolves against the loop alias first (e.g. applicant.name), then falls back to the surrounding data for anything the alias doesn't shadow (e.g. orgName from outside the loop) — see Structural nodes.
  • On the client, evaluating a node's bindings is also what subscribes that node to the store — see Controlled inputs & state.

Expression language

Everything inside {{ }} is a small, sandboxed formula language — never executable JavaScript. Template configs are typically authored by one party and rendered for another (including server-side, for PDF/email delivery), so the language surface is a closed, fixed set: no eval, no Function(), no way to reach a constructor, a prototype, or any value not explicitly reachable from your data.

Values & paths

{{ name }}                 identifier
{{ user.email }}           dot path
{{ items[0].label }}       bracket index (numeric or string)
{{ "literal string" }}     string literal ('single' or "double" quotes)
{{ 42 }}  {{ 3.14 }}       number literal
{{ true }}  {{ false }}  {{ null }}

Paths only ever read own properties of the data you pass in — __proto__, constructor, and prototype are always blocked, and inherited methods/fields are never visible to a template. A missing path evaluates to undefined, never throws.

Operators

!a  -a  +a  ~a                                unary
a + b  a - b  a * b  a / b  a % b  a ** b     arithmetic
a & b  a | b  a ^ b  a << b  a >> b  a >>> b  bitwise
a === b  a !== b  a == b  a != b
a < b  a > b  a <= b  a >= b                  comparison
a && b  a || b  a ?? b                        logical (short-circuit; returns
                                               the operand, not a boolean — same
                                               as JS: `0 && 5` is `0`, not `false`)
a ? b : c                                     ternary

Helpers

Bare function calls resolve against a fixed, pure helper registry — anything not in the list below is a parse-time error, not a silent no-op:

| Helper | Signature | Notes | | --- | --- | --- | | formatDate | formatDate(value, format?) | format is a token string ("YYYY-MM-DD", "HH:mm", default "YYYY-MM-DD") or a named style ("short"/"medium"/"long"/"full"). Always reads UTC fields, so SSR and the client render the same calendar date regardless of either machine's timezone. | | formatCurrency | formatCurrency(value, currency?, locale?) | Defaults "USD" / "en-US". | | upper / lower / trim | (value) | String utilities; nullish-safe. | | concat | (...values) | Joins with no separator, nullish-safe. | | join | (array, separator?) | Default separator ", ". | | default | (value, fallback) | Falls back on null/undefined/"" (not 0 or false). | | length | (value) | String or array length; 0 otherwise. | | count | (array) | Array length; 0 for non-arrays. | | sum | (array) | Coerces entries to numbers, non-numeric entries count as 0. | | min / max | (...values) | Accepts nested arrays or bare numbers. | | round | (value, digits?) | Default 0 digits. |

A function reached any other way — data.someField(), x.constructor(), a helper name that isn't registered — is always a hard error rather than undefined, so a typo'd helper fails loudly instead of silently rendering blank.

Every helper is pure and synchronous — no network calls, no Date.now()/new Date() (ambient clock reads), no randomness. SSR output is a pure function of (template, data), so nothing a helper does can desync a server render from the client.

Lambdas over arrays

A fixed set of array methods accept an inline arrow-style callback:

{{ applicants.filter(a => a.age >= 18).map(a => a.name) }}
{{ applicants.some(a => a.age < 18) }}
{{ applicants.every(a => a.verified) }}
{{ applicants.find(a => a.age > 65) }}
{{ applicants.map((a, i) => i) }}          -- second param is the index

Allowlisted methods: map, filter, some, every, find. Anything else — .push(), .constructor(), a method on a non-array value — is rejected before the receiver is even touched. The a => … syntax isn't compiled or eval'd into a real JavaScript function: the arrow body is walked by the same interpreter that evaluates the rest of the expression, with the parameter bound in a scope frame that falls back to the surrounding data for any other identifier the body references.

Structural nodes: each & if

each and if are structural node types — they control what renders, not what's rendered. Both are strict schemas: an each/if node with an unrecognized arg fails validation rather than silently ignoring it.

if — renders its children when condition is truthy, nothing otherwise. No wrapper element is introduced; multiple children render as siblings.

{
  "type": "if",
  "args": { "condition": "{{applicant.active}}" },
  "children": [{ "type": "p", "args": {}, "children": ["{{applicant.name}}"] }]
}

each — loops over an array binding, introducing an alias for each item:

{
  "type": "each",
  "args": { "items": "{{applicants}}", "as": "applicant", "index": "i", "key": "{{applicant.id}}" },
  "children": [{ "type": "p", "args": {}, "children": ["{{i}}", ": ", "{{applicant.name}}"] }]
}
  • items (required): a binding resolving to an array.
  • as (required): the alias name each row's children use to refer to the current item (applicant.name, not applicants[i].name).
  • index (optional): an alias for the current loop index (a plain number, not a store path — an index has nothing to write back to).
  • key (optional): a binding evaluated per-row to key React's reconciliation (e.g. {{applicant.id}}). Strongly recommended whenever rows can be reordered or removed — without it, rows fall back to positional (array-index) identity, which can silently reuse a DOM/component instance for the wrong logical row after a reorder.
  • Identifiers inside a row resolve against the alias first, then fall back to the surrounding data for anything the alias doesn't shadow — so {{orgName}} still works inside a loop over applicants even though orgName isn't a field on applicant.
  • Two-way binding inside a row (e.g. an <input> bound to {{applicant.name}}) writes back to the row's real store path (applicants.<index>.name), as long as items is a plain path binding. A computed/filtered array ({{applicants.filter(...)}}) has no 1:1 index correspondence back to the store, so rows render read-only in that case.
  • each nodes can nest; each level's alias stacks on top of the outer one.

Controlled inputs & state

  • Each mounted Bonsai instance gets its own valtio store — not a shared module-level singleton — holding { status, expiry, data, submitting }. It's reachable only via React context, from components mounted inside that instance's subtree.
  • useUpdateData() (call it from inside the subtree) returns an updateData(path, value) function that mutates the store's data using the same path grammar as bindings.
  • Controlled components registered in controlledInputMap automatically wire up change handlers:
    • Value props point at a binding (e.g. value: "{{profile.name}}").
    • Change events feed straight into updateData — no debounce, no local-echo state. Per-node subscriptions make every keystroke's store write and re-render cheap enough that this stays snappy, and it's what lets dependent expressions ({{price*qty}}, an if condition) update in the same event as the keystroke that changed them.
    • When something mutates the store's data, every node whose binding reads that path re-renders; unrelated nodes don't.
  • Non-controlled components simply receive evaluated props and behave as standard React elements.
  • Submission is handled internally: the client render mounts its own <form> and POSTs { data: store.data } to captureUrl on submit.
  • Reactivity model: every leaf reads the store itself (via valtio's useSnapshot) and evaluates its own bindings against that snapshot — the read is the subscription. A node with no bindings at all (a plain layout <div>) is detected and skipped from this entirely, so it never re-renders due to an unrelated store write. This is what keeps a keystroke in one field from re-rendering the rest of the form.

Component reference

The full, authoritative list of component types is the COMPONENT enum in @bonsai/constants (library/constants/src/components.ts) — componentMap (src/lib/ui/registry.ts) has an entry for every value in it.

| Category | Schema types | | --- | --- | | Structural (control what renders, not a real element) | each, if | | Plain elements | div, span, a, p, h, ol, ul, li, image | | Layout / display | card (+ card-header, card-title, card-description, card-content, card-footer, card-action, card-icon), accordion (+ accordion-item, accordion-trigger, accordion-content), collapsible (+ collapsible-trigger, collapsible-content), alert, avatar, badge, separator, skeleton, loader, progress | | Tabular | table (+ table-header, table-body, table-footer, table-head, table-row, table-cell, table-caption) | | Interactive / controlled inputs | input (subtypes: text, email, password, number, and a default fallback), textarea, checkbox, switch, otp, calendar, map, signature, input-group (+ input-group-text, input-group-addon, input-group-input, input-group-button, input-group-textarea) | | Other | button, label |

Controlled inputs (registered in controlledInputMapinput, otp, checkbox, textarea, input-group-input, input-group-textarea, switch, signature, calendar, map) automatically wire up a value prop and change handler as described in Controlled inputs & state. Everything else just receives its evaluated args as props.

Need a component that isn't listed? Add it to COMPONENT/COMPONENT_LIST in @bonsai/constants, add its prop contract to @bonsai/schemas, and register the React implementation in componentMap (and controlledInputMap if it should behave as a controlled input) — see Extending Bonsai.

Styling

Bonsai is styled with Tailwind CSS v4. Components emit plain Tailwind utility classes, and templates can pass any additional utilities through className on any node.

  • The design system lives in src/index.css: an @theme token set (an OKLCH color palette with swappable accent colors, typography, shadows) plus base and utility layers, custom fonts (Sora, Fredoka, and Great Vibes for signatures) loaded via @font-face, and style overrides for the embedded map (MapLibre) and chart (Recharts) widgets.
  • Every rendered tree is wrapped in a .bonsai-root element carrying a data-mode attribute (web / pdf / preview), so hosts can scope their own overrides per render mode.
  • Getting the stylesheet: inside the Pebble Bonsai platform, CSS is compiled per-template at form-creation time — only the classes a template actually uses are emitted. When consuming this package standalone, compile the styles with your own Tailwind v4 build: import the Bonsai theme and add the package (and your template sources) as content via @source so the utilities your templates reference are generated. A prebuilt, ready-to-import stylesheet export is planned while the packaging story stabilizes during the preview period.
  • Theming: the theme is token-driven — redefine the CSS variables (e.g. --color-main, --font-heading) after the theme to restyle every component at once, or target .bonsai-root descendants for finer overrides.

Extending Bonsai

  1. Add a component:
    • Extend COMPONENT and COMPONENT_LIST in @bonsai/constants.
    • Add prop validation in @bonsai/schemas.
    • Register the React implementation in componentMap.
    • Optionally register controlled behaviour in controlledInputMap.
  2. Custom data handling:
    • Use useUpdateData() from a component mounted inside a Bonsai instance's subtree for simple mutations.
  3. Alternate styling:
    • Redefine the theme's CSS variables before mounting Bonsai — see Styling.

Development

Issues and pull requests are welcome at PebbleSoftwareInc/pebble-bonsai. This package lives at apps/bonsai in the monorepo.

pnpm lint        # Static analysis using ESLint
pnpm test        # Run the test suite (vitest)
pnpm type-check  # TypeScript without emit
pnpm build       # Bundles dist/ (ESM + type declarations) via Vite

dist/ contains the ESM bundle and TypeScript declarations consumed by downstream applications. The shared @bonsai/constants and @bonsai/schemas workspaces are bundled in, so the published package has no workspace dependencies.