Easy Labs
Frontend SDKsJavaScript

Elements

Iframed card fields and tokenization for custom payment forms.

Elements are framework-agnostic mount functions that drop a Basis Theory iframe into a container element on your page. The iframe owns the card data — the PAN never reaches your origin — and the SDK exposes a small ElementHandle you can use to focus, clear, observe validation, and tokenize.

Reach for Elements when you need to render the form yourself (custom layout, custom submit button, custom branding) but still want PCI-isolated inputs. If you'd rather not paint the form at all, use Embedded Checkout instead.

Elements were added in @easylabs/browser 0.2.0. They are the vanilla equivalents of the React components in @easylabs/react (<CardNumberElement>, <CardExpirationDateElement>, <CardVerificationCodeElement>, <TextElement>) — same option names, same lifecycle, just imperative.

Available elements

FunctionMountsReact equivalent
mountCardNumberElement(target, options?)A card-number input. Surfaces brand on change.<CardNumberElement>
mountCardExpirationDateElement(target, options?)An MM / YY expiration input.<CardExpirationDateElement>
mountCardVerificationCodeElement(target, options?)A CVC input. Pass the brand from the card-number element to validate length (Amex = 4 digits, everything else = 3).<CardVerificationCodeElement>
mountTextElement(target, options?)A generic text input — ACH routing/account numbers, OTPs, anything else you want to keep off your origin.<TextElement>

All four return the same ElementHandle shape.

Quick example

import {
  createEasyClient,
  mountCardNumberElement,
  mountCardExpirationDateElement,
  mountCardVerificationCodeElement,
  tokenize,
} from "@easylabs/browser";

// Validates the key and bootstraps the underlying Basis Theory client.
await createEasyClient({ apiKey: "sk_test_..." });

const cardNumber = mountCardNumberElement("#card-number", {
  placeholder: "1234 5678 9012 3456",
  classes: { focus: "is-focused", complete: "is-complete", invalid: "is-invalid" },
});
const cardExpiration = mountCardExpirationDateElement("#card-exp", {
  placeholder: "MM / YY",
});
const cardCvc = mountCardVerificationCodeElement("#card-cvc", {
  placeholder: "CVC",
});

document.getElementById("pay")!.addEventListener("click", async () => {
  const token = await tokenize({ cardNumber, cardExpiration, cardCvc });
  // token: { id: string, type: "card", fingerprint: string }
  // Forward token.id to your backend to create a payment instrument.
});

The matching markup:

<div id="card-number"></div>
<div id="card-exp"></div>
<div id="card-cvc"></div>
<button id="pay" type="button">Pay</button>

Mount options

All four element factories accept the same ElementOptions shape. mountCardVerificationCodeElement and mountTextElement add a few extra fields (documented below).

OptionTypeDescription
placeholderstringPlaceholder shown inside the iframe input.
disabledbooleanDisable user input.
stylePartial<CSSStyleDeclaration>Inline styles applied to the wrapper element around the iframe.
classNamestringclassName applied to the wrapper element.
classes{ focus?: string; complete?: string; invalid?: string }Class names applied to the wrapper when the underlying iframe reports the matching state. Lets you style focus/complete/invalid the same way Stripe.js users expect.

mountCardVerificationCodeElement adds:

OptionTypeDescription
cardBrand'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown'Brand from the card-number element. Drives CVC length validation (Amex = 4 digits, others = 3).

mountTextElement adds:

OptionTypeDescription
maskstringInput mask, e.g. "### ### ####". Formats the value as the user types.
transform'uppercase' | 'lowercase'Auto-transform input before tokenisation.

The ElementHandle

Every mount* returns the same shape:

interface ElementHandle {
  /** Tear down the iframe and stop emitting events. */
  unmount(): void;
  /** Move focus into the iframe. */
  focus(): void;
  /** Remove focus from the iframe. */
  blur(): void;
  /** Reset the element to an empty state. */
  clear(): void;
  /** Subscribe to an element event. */
  on<E extends ElementEventName>(event: E, cb: ElementEvents[E]): void;
  /** Unsubscribe a previously-registered listener. */
  off<E extends ElementEventName>(event: E, cb: ElementEvents[E]): void;
  /** Snapshot the current validation/completion state. */
  getState(): ElementState;
}

focus, blur, and clear are safe to call before the iframe finishes its async mount — calls are queued and applied as soon as the iframe is ready.

Events

type ElementEvents = {
  ready: () => void;
  change: (state: ElementState) => void;
  focus: () => void;
  blur: () => void;
  error: (err: Error) => void;
};

type ElementState = {
  empty: boolean;
  complete: boolean;
  errorMessage?: string;
  /** Card-number element only. */
  brand?: 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown';
};

The change event fires whenever the iframe reports a state transition. Card-number elements additionally surface brand so you can render a card-network logo or pass it into mountCardVerificationCodeElement for length validation:

const cardNumber = mountCardNumberElement("#card-number");
cardNumber.on("change", (state) => {
  console.log("brand:", state.brand);  // "visa" | "mastercard" | …
  console.log("complete:", state.complete);
});

Listener errors are caught and re-emitted through the error channel so a single broken handler doesn't break siblings.

Styling

The wrapper around the iframe is a plain <div> you control via style, className, and classes. The iframe inside that wrapper is opaque — Basis Theory owns the rendered input.

The Stripe-like state classes give you the most leverage. The example below paints a focus ring, a green border on completion, and a red border on invalid input:

const cardNumber = mountCardNumberElement("#card-number", {
  className: "el-host",
  classes: {
    focus: "is-focused",
    complete: "is-complete",
    invalid: "is-invalid",
  },
});
.el-host {
  border: 1px solid #d8d8df;
  border-radius: 8px;
  padding: 0.625rem 0.75rem;
  height: 44px;
  background: white;
}
.el-host.is-focused { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08); }
.el-host.is-complete { border-color: #16a34a; }
.el-host.is-invalid { border-color: #dc2626; }

Tokenization

Once your three card elements are mounted, call tokenize to exchange them for a Basis Theory token reference:

import { tokenize } from "@easylabs/browser";

const token = await tokenize({ cardNumber, cardExpiration, cardCvc });
// token.id         — pass to createPaymentInstrument or include in a session
// token.type       — "card"
// token.fingerprint — stable identifier for dedup / fraud checks

tokenize rejects with an explanatory error if any element is not yet mounted. Wait for ready before exposing the submit button, or simply gate the click handler on getState().complete for all three.

Forward token.id to your backend to create a payment instrument:

// server.ts
const { data: instrument } = await easy.createPaymentInstrument({
  type: "PAYMENT_CARD",
  identityId: customerId,
  tokenId: token.id,
});

Cleanup

Each element creates an iframe and a small bag of postMessage listeners. Call handle.unmount() on route change / component teardown:

const cardNumber = mountCardNumberElement("#card-number");
// later …
cardNumber.unmount();

Forgetting to unmount leaks the iframe and its listeners. In a long-lived SPA that mounts the form repeatedly, this matters.

Bootstrap order

Element factories require an initialised Basis Theory client. The recommended bootstrap is:

  1. await createEasyClient({ apiKey }) — validates the key and stamps the API URL onto a window-level global.
  2. Call any number of mount*Element(...) factories.

If you'd rather skip createEasyClient (for example, you only need Elements + tokenize and you don't want the typed EasyApiClient surface), you can configure the underlying client directly:

import { configureBasisTheoryFromKey } from "@easylabs/browser";

configureBasisTheoryFromKey("sk_test_...", "https://api.itseasy.co");

Calling a mount* factory before either bootstrap path will surface an error event explaining the missing client.

On this page