@bro-code/checkout
v2026.0.0
Published
A themed Stripe checkout dialog for any web app: Stripe's Checkout Elements in a framework-free dialog, with a theme for each app.
Maintainers
Readme
@bro-code/checkout
A themed Stripe checkout dialog for any web app: Stripe's Checkout Elements in a framework-free dialog, with a theme for each app.
One call opens a dialog over the app with:
- the order summary: each item, any discount and tax added on top, the total (with the tax a tax-included price holds), and what a subscription costs after today (after a free trial, when it has one);
- an email field, when the Checkout Session has no email yet;
- Stripe's Billing Address Element, on every checkout: the session doesn't say whether it requires the billing address, and one that does can't be paid without it;
- Stripe's Payment Element, themed to match the app;
- a Pay button that says what's paid (
Pay $4.99,Start free trial, orConfirmwhen nothing is due today), and any decline or error under it.
It resolves when the payment is confirmed, so the player never leaves the app for Stripe's hosted page. It works with any library or none, and does nothing when imported: Stripe.js loads from js.stripe.com on the first checkout. Without a window (server rendering), createCheckout does nothing and returns a controller whose open resolves closed.
Installation
npm install @bro-code/checkout@stripe/stripe-js, Stripe's own loader, comes with it.
Usage
Keep the settings in the app's bro.config.json:
{
"checkout": {
"theme": "rombie"
}
}A browser can't read that file at runtime, so import the section and pass it in, with the parts only code can supply (TypeScript needs resolveJsonModule, which moduleResolution: "bundler" turns on; Vite keeps only the imported section in the bundle):
import { createCheckout } from '@bro-code/checkout';
import { checkout as checkoutConfig } from '../bro.config.json';
const checkout = createCheckout({
...checkoutConfig,
// Test and live keys differ, so the key comes from the app's env.
publishableKey: import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY,
});A CommonJS build is included too: const { createCheckout } = require('@bro-code/checkout');.
Call it once, at startup. A second call (a dev server's hot reload, or the app switching between dark and light) replaces the options, restyles a checkout that's open, and returns the same controller.
Then open a checkout for a session the app's server created:
const result = await checkout.open({
// Called when the dialog opens, so it shows while the server works.
clientSecret: () => api.startCheckout(slug).then((response) => response.clientSecret),
});
if (result.status === 'complete') {
// Tell the server, as a return from Stripe's hosted page would.
await api.confirmPurchase(slug, result.sessionId);
}open resolves { status: 'complete', sessionId, session } when the payment is confirmed and { status: 'closed' } when the player closes the dialog. It rejects, after closing the dialog, when the checkout can't start: the clientSecret function rejects (its own error comes through, so the app can show it as it does now), Stripe.js can't load, or the session can't be loaded or has expired. A declined card isn't a rejection: the dialog shows Stripe's message and the player can try again.
checkout.close(); // resolves the open checkout's promise with { status: 'closed' }While a payment is being confirmed, close(), Escape and the close button do nothing, and a second open rejects.
To show the form in the app's own page instead of a dialog, give it a container. It has no close button there; close() ends it and empties the container.
await checkout.open({ clientSecret, container: document.querySelector('#pay')! });Options
In bro.config.json (CheckoutConfig)
| Field | Default | |
|---|---|---|
| theme | default | default, brocode, rombie, taut-wad or custom. See Themes. |
| mode | dark | dark or light: which of the theme's two versions to use. |
| colors | | With theme: 'custom', any of the ten theme values over default's. |
| fonts | | [{ "cssSrc": "<stylesheet URL>" }]: stylesheets with @font-face rules for Stripe's fields. See Fonts. |
Code only (CheckoutOptions)
| Field | |
|---|---|
| publishableKey | Required. The Stripe account's publishable key, pk_test_... or pk_live_.... Anything else throws a TypeError. |
| appearance | Stripe Appearance API settings over the theme's, for Stripe's fields only: variables and rules are merged, anything else replaced. |
| onOpenChange | (open: boolean) => void, called when a checkout opens and closes. |
Anything else invalid falls back with a [checkout] warning in the console.
Each checkout (CheckoutRequest)
| Field | |
|---|---|
| clientSecret | Required. The client secret of a Checkout Session created with ui_mode: 'elements', or a function that returns a promise of one. |
| title | The heading. Default: the first line item's name (Checkout while it loads). |
| returnUrl | Where the browser comes back to after a payment method that has to leave the page (a bank redirect). Default: the session's return_url. Cards and wallets never leave. |
| container | An element to show the form in, instead of the dialog. |
Themes
Each theme has a dark and a light version. rombie has no light theme, so it uses its dark one in both modes; taut-wad uses brocodetech's palette, as the app does.
| Value | default dark / light | brocode and taut-wad dark / light | rombie |
|---|---|---|---|
| background | #121417 / #ffffff | #181818 / #dddddd | #0a0a12 |
| surface | #1c1f24 / #f4f6f8 | #222222 / #eeeeee | #15141f |
| text | #e8eaed / #1f2328 | #ffffff / #000000 | #f1eef9 |
| muted | #9aa0a6 / #59636e | #b0b0b0 / #5f5f5f | #9a90bd |
| border | #33373d / #d0d7de | #3c3c3c / #bdbdbd | #302c47 |
| accent | #5b9cff / #1a66d9 | #7b9981 / #7b9981 | #ff2d78 |
| accentText | #0b1220 / #ffffff | #ffffff / #ffffff | #12060d |
| error | #ff6b6b / #c62828 | #ff5252 / #b95252 | #ff5c5c |
| success | #6fcf97 / #1e7e34 | #96be4d / #7a9a3f | #00e5ff |
| font | system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif | Roboto, sans-serif | system-ui, 'Segoe UI', Roboto, sans-serif |
custom starts from default in the mode given:
{ "checkout": { "theme": "custom", "mode": "light", "colors": { "accent": "#e91e63", "font": "Georgia, serif" } } }Each color is checked with CSS.supports('color', ...) and the font with CSS.supports('font-family', ...); a missing or invalid one keeps default's, with a warning for the invalid ones. An unknown theme is default, and an unknown mode dark, with a warning.
To follow an app that switches between dark and light, call createCheckout again with the new mode:
watch(isDark, (dark) => createCheckout({ ...options, mode: dark ? 'dark' : 'light' }));How a theme reaches Stripe's fields
Stripe's fields are iframes from js.stripe.com, so the page's CSS can't reach them. The theme is passed to Stripe as Appearance API settings (toAppearance gives them, for an app that builds its own Stripe form):
nightis the base in dark mode andstripein light, for anything the variables don't cover;accentiscolorPrimary(andaccentTextiscolorPrimaryText),surfaceiscolorBackground(the fields),textiscolorText,mutediscolorTextSecondaryand the placeholder,erroriscolorDanger,successiscolorSuccess, andfontisfontFamily;- fields, tabs and blocks get a 1px
borderand anaccentring when focused, and every radius is 8px, the same as the dialog's.
Fonts
Stripe's fields can't use the page's web fonts. Without fonts, they use the first font in the theme's font that the device has (Roboto where it's installed, else the system's sans-serif). To load one, give Stripe a stylesheet with its @font-face rules:
{ "checkout": { "theme": "brocode", "fonts": [{ "cssSrc": "https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600&display=swap" }] } }Restyling
The dialog is in the page's own DOM, not a shadow root, because Stripe doesn't support its fields in one. Its rules are all under .bc-checkout, so a page's element rules (a global button { }) don't reach it, and its theme is set as custom properties on the .bc-checkout root, which a page rule with !important overrides:
.bc-checkout {
--bcc-accent: #e91e63 !important;
}The properties are --bcc-background, --bcc-surface, --bcc-text, --bcc-muted, --bcc-border, --bcc-accent, --bcc-accent-text, --bcc-error, --bcc-success and --bcc-font. They style the dialog around Stripe's fields; restyle the fields themselves with appearance. The root also carries data-mode="dark" or "light".
The server's part
The app's server creates the Checkout Session with Stripe's API and returns only its client secret. With stripe-node:
const session = await stripe.checkout.sessions.create({
ui_mode: 'elements',
mode: 'payment',
customer_email: user.email, // or customer: ...; without either, the dialog asks for an email
line_items: [{ price_data: { currency: 'usd', unit_amount: 499, product_data: { name: 'StarShip RPG' } }, quantity: 1 }],
return_url: 'https://app.example.com/store?session_id={CHECKOUT_SESSION_ID}',
metadata: { userId: String(user.id) },
});
res.json({ clientSecret: session.client_secret });ui_mode: 'elements'needs Stripe API version2026-03-25.dahliaor later. Dahlia renamed it fromcustom, andcustomfails from then on. An older version pinned for the rest of the server can be overridden on this one call:stripe.checkout.sessions.create(params, { apiVersion: '2026-03-25.dahlia' }).- A session for a subscription (
mode: 'subscription', with a trial or without) works the same way. - For sales tax through Stripe Tax, add
automatic_tax: { enabled: true }andbilling_address_collection: 'required'. Stripe then wants the buyer's full billing address, which the dialog's address fields above the payment fields collect, and the tax line appears once Stripe has the address. A session for an existingcustomeralso needscustomer_update: { address: 'auto' }, so Stripe can save the address onto the customer. - The session's
return_urlreplacessuccess_urlandcancel_url: there's nothing to cancel back to, since the player never leaves, andreturn_urlis only used by a payment method that has to. - Keep fulfilling from Stripe's
checkout.session.completedwebhook.openresolvingcompleteis the moment to tell the server and update the page, but the server should check the session with Stripe before granting anything.
Content Security Policy
A site with a CSP must allow Stripe.js, as Stripe's CSP list says: https://js.stripe.com in script-src and frame-src, and https://api.stripe.com in connect-src, plus the hosts for 3-D Secure and any wallets the account uses. The dialog's own styles are a constructable stylesheet where the browser has them, which style-src allows without 'unsafe-inline'.
API
| Export | |
|---|---|
| createCheckout(options) | Sets everything up and returns { open(request), close() }. |
| toAppearance(colors, mode, override?) | A theme's colors as Stripe Appearance API settings, as the dialog gives them to Stripe. |
| THEMES | Each preset's dark and light colors. |
| resolveColors(config) | The colors a config's theme, mode and colors give, as the dialog uses them. |
| Types | CheckoutConfig, CheckoutOptions, CheckoutRequest, CheckoutResult, CheckoutController, CheckoutTheme, CheckoutMode, CheckoutColors. |
The dialog is role="dialog" with aria-modal="true", labelled by its heading. While it's open, the app's own elements in document.body are made inert, so focus stays in the dialog, and focus goes back to what opened it when it closes; Escape closes it. It isn't a native modal <dialog>, because showModal() makes the rest of the page inert, and Stripe adds 3-D Secure's challenge to the page's body. Stripe's own frames in the body are never made inert.
License
MIT - see LICENSE.
Changelog
[2026.0.0] - 2026-11-09
Added
- Initial release.
createCheckout(options), withopen(request)andclose(): Stripe's Checkout Elements in a themed dialog, or in a container.- The order summary (line items, discount, tax, total, a subscription's price after today and its trial), an email field when the session has none, the Payment Element and the Pay button.
- Sales tax: the Billing Address Element on every checkout, since a session that requires the billing address (
automatic_taxwithbilling_address_collection: 'required') can't be confirmed without it, and an "Includes $X tax" note under the total for a tax-included price. - The
default,brocode,rombie,taut-wadandcustomthemes, each indarkandlight, the--bcc-*properties,appearanceandfonts. toAppearance,THEMESandresolveColors.
