Skip to main content

Theming

v3 introduces a design token system that lets you apply consistent visual styles across all elements from a single configuration object. Tokens control colors, typography, spacing, borders, and field height — all from a single theme object passed at initialization.

How theming works​

Theme tokens are passed to BasisTheory() at initialization and injected into every element iframe as CSS variables. Elements use those variables for their appearance.

BasisTheory(key, { theme, darkTheme, themeMode })
└─ injected as CSS variables into all element iframes

Basic setup​

import BasisTheory from '@basis-theory/web-elements';

const bt = BasisTheory('<PUBLIC_API_KEY>', {
themeMode: 'auto', // 'light' | 'dark' | 'auto' — defaults to 'auto'
fonts: [{ family: 'Inter', src: '/fonts/inter-latin-wght-normal.woff2', weight: '100 900' }],
theme: {
colors: {
primary: '#6366f1',
error: '#ef4444',
success: '#22c55e',
text: {
primary: '#111827',
secondary: '#6b7280',
placeholder: '#9ca3af',
disabled: '#d1d5db',
},
background: {
default: '#ffffff',
hover: '#f9fafb',
focus: '#f5f3ff',
disabled: '#f3f4f6',
},
border: {
default: '#d1d5db',
hover: '#a5b4fc',
focus: '#6366f1',
error: '#ef4444',
},
},
typography: {
fontFamily: 'Inter, sans-serif',
fontSize: {
sm: '14px',
base: '16px',
lg: '18px',
},
fontWeight: {
normal: '400',
medium: '500',
semibold: '600',
},
},
spacing: {
xs: '4px',
sm: '8px',
md: '12px',
lg: '16px',
xl: '24px',
},
borders: {
radius: {
none: '0',
sm: '2px',
base: '6px',
lg: '12px',
full: '9999px',
},
width: {
thin: '1px',
base: '1.5px',
thick: '2px',
},
},
},
});

fonts makes Inter available inside the element iframes. Without it, the fields only render Inter on devices that have it installed. See Use your own font.

Use your own font​

Element fields render inside iframes, and an iframe cannot use the fonts your page loads. When typography.fontFamily names a web font, such as your brand font or one from Google Fonts, the fields fall back to the next family in the stack on any device that does not have that font installed, while the inputs around them keep your font.

Pass the font in the fonts option and name the same family in typography.fontFamily:

const bt = BasisTheory('<PUBLIC_API_KEY>', {
fonts: [
{ family: 'Brand Sans', src: '/fonts/brand-sans.woff2', weight: '100 900' },
],
theme: {
typography: {
fontFamily: 'Brand Sans, sans-serif',
fontSize: { base: '16px' },
fontWeight: { normal: '400' },
},
},
});

The SDK fetches each file from your page when the first element mounts, once per BasisTheory() instance, and hands every element the font data. Elements never fetch fonts themselves. Mounting does not wait for fonts: the text renders in the fallback font and switches when the font arrives, which is usually immediate when your page already uses the same file. Fonts apply to every element that renders text, including cardDisplay values and copyButton labels.

PropertyTypeDefaultDescription
familystring—Name to use in typography.fontFamily. Words of letters, digits, _ or -, each starting with a letter, up to 64 characters. It is an alias you choose, so a font named Press Start 2P can be registered as PressStart.
srcstring | ArrayBuffer—URL of a WOFF2, WOFF, TrueType or OpenType file, resolved against your page, or the file's bytes. Up to 1 MB per file.
weightstring'400'Weight the file provides, from 1 to 1000, or a range such as '100 900' for a variable font.

You can pass up to eight fonts. An invalid family or weight throws when you call BasisTheory(). A file that cannot be loaded logs a console warning that names the font and the reason, and the fields keep the fallback font. See Custom font not applied.

Where to host the font​

Serving the font from your own domain is the most reliable option. A font on another domain works when that server allows cross-origin requests (CORS), which Google Fonts and most font CDNs do. If your page enforces a Content Security Policy, its connect-src directive must allow the font's origin, because the SDK fetches the font from your page. font-src does not apply to this request.

Prefer the Latin subset in WOFF2 when the provider offers it. It is typically 20 to 50 KB.

Load a Google Font​

src must point to the font file. The stylesheet URL that Google Fonts gives you, such as https://fonts.googleapis.com/css2?family=Inter, returns CSS, so the SDK rejects it with a console warning. Use one of these instead:

// Self-hosted: download the font file once and serve it from your site.
fonts: [{ family: 'Inter', src: '/fonts/inter-latin-wght-normal.woff2', weight: '100 900' }],

// Fontsource on jsDelivr: Google Fonts packaged with versioned URLs.
fonts: [
{
family: 'Inter',
src: 'https://cdn.jsdelivr.net/npm/@fontsource-variable/inter@5.3.0/files/inter-latin-wght-normal.woff2',
weight: '100 900',
},
],

// Google Fonts directly: open https://fonts.googleapis.com/css2?family=Inter:wght@100..900
// in a browser and copy the woff2 URL from its /* latin */ block.
fonts: [
{
family: 'Inter',
src: 'https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw.woff2',
weight: '100 900',
},
],

If the file covers a single weight, set weight to that weight, such as weight: '400'. A range like '100 900' tells the browser one file covers every weight, so it won't synthesize bold and medium or semibold text renders as regular.

Google can change the file URLs in its stylesheets when a font is updated, so for production prefer a self-hosted file or a versioned Fontsource URL.

Dark mode​

Pass a darkTheme object to provide a separate set of tokens for dark mode. When themeMode is 'auto', the SDK applies theme or darkTheme based on the OS prefers-color-scheme media query.

const bt = BasisTheory('<PUBLIC_API_KEY>', {
themeMode: 'auto',
fonts: [{ family: 'Inter', src: '/fonts/inter-latin-wght-normal.woff2', weight: '100 900' }],
theme: {
colors: {
primary: '#6366f1',
text: { primary: '#111827' },
background: { default: '#ffffff' },
border: { default: '#d1d5db', focus: '#6366f1' },
error: '#ef4444',
success: '#22c55e',
},
typography: { fontFamily: 'Inter, sans-serif', fontSize: { base: '16px' }, fontWeight: { normal: '400' } },
spacing: { sm: '8px', md: '12px', lg: '16px' },
borders: { radius: { base: '6px' }, width: { base: '1.5px' } },
},
darkTheme: {
colors: {
primary: '#818cf8',
text: { primary: '#f9fafb' },
background: { default: '#1f2937' },
border: { default: '#374151', focus: '#818cf8' },
error: '#f87171',
success: '#4ade80',
},
typography: { fontFamily: 'Inter, sans-serif', fontSize: { base: '16px' }, fontWeight: { normal: '400' } },
spacing: { sm: '8px', md: '12px', lg: '16px' },
borders: { radius: { base: '6px' }, width: { base: '1.5px' } },
},
});

If darkTheme is omitted and themeMode is 'auto' or 'dark', elements fall back to using the light theme tokens.

Switching theme mode at runtime​

Call bt.updateThemeMode() to switch all mounted elements to a new mode without remounting:

// Toggle between light and dark
const current = getCurrentMode(); // your own state
await bt.updateThemeMode(current === 'light' ? 'dark' : 'light');

To update a single element's theme mode without affecting others, use element.update():

await cardNumberEl.update({ themeMode: 'dark' });

Setting the field height​

By default, an input field is as tall as its vertical padding plus its line height, and the card number field never renders shorter than 48px. If your form uses a different field height, set sizing.input.height so the elements match it:

const bt = BasisTheory('<PUBLIC_API_KEY>', {
theme: {
sizing: {
input: { height: '32px' },
},
},
});

Every input renders at exactly that height: cardNumber, expiry, cvv, text, and each field of card. The vertical padding is dropped so the text stays centered, and the horizontal padding still follows spacing.md.

The cardNumber iframe resizes itself to the configured height. The other elements fill the container you mount them in, so give those containers the same height. For card, size the container to fit every row its layout renders.

When the co-badge network list opens, the cardNumber iframe grows to show it and returns to the configured height once it closes. To keep the list from pushing the rest of your form down, give the container a fixed height, overflow: visible, and position: relative with a z-index, so the list renders over the content below it.

If darkTheme doesn't set sizing, dark mode uses the height from theme.

Design token reference​

All tokens have built-in defaults — you only need to provide the tokens you want to override.

colors​

TokenTypeDescription
colors.primarystringPrimary accent color (focus rings, active states)
colors.errorstringError state color
colors.successstringSuccess / complete state color
colors.text.primarystringMain input text color
colors.text.secondarystringSecondary text color
colors.text.placeholderstringPlaceholder text color
colors.text.disabledstringText color when element is disabled
colors.background.defaultstringDefault background
colors.background.hoverstringBackground on hover
colors.background.focusstringBackground when focused
colors.background.disabledstringBackground when disabled
colors.border.defaultstringDefault border color
colors.border.hoverstringBorder color on hover
colors.border.focusstringBorder color when focused
colors.border.errorstringBorder color in error state

typography​

TokenTypeDescription
typography.fontFamilystringFont stack. Web fonts must also be passed in fonts.
typography.fontSize.smstringSmall font size
typography.fontSize.basestringDefault font size
typography.fontSize.lgstringLarge font size
typography.fontWeight.normalstringNormal weight
typography.fontWeight.mediumstringMedium weight
typography.fontWeight.semiboldstringSemibold weight
typography.lineHeight.tightstringTight line height
typography.lineHeight.normalstringNormal line height
typography.lineHeight.relaxedstringRelaxed line height

spacing​

TokenDescription
spacing.xsExtra-small spacing
spacing.smSmall spacing
spacing.mdMedium spacing
spacing.lgLarge spacing
spacing.xlExtra-large spacing

sizing​

TokenTypeDescription
sizing.input.heightstringExact height of every input field, as a CSS length ('32px'). When omitted, the height comes from padding and line height, and the card number field has a 48px minimum

borders​

TokenDescription
borders.radius.noneNo radius ('0')
borders.radius.smSmall radius
borders.radius.baseDefault radius
borders.radius.mdMedium radius
borders.radius.lgLarge radius
borders.radius.fullFull / pill radius
borders.width.thinThin border width
borders.width.baseDefault border width
borders.width.thickThick border width

shadows (optional)​

shadows: {
none: 'none',
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
base: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
focus: '0 0 0 3px rgba(59, 130, 246, 0.1)',
}

transitions (optional)​

transitions: {
fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)',
base: '200ms cubic-bezier(0.4, 0, 0.2, 1)',
slow: '300ms cubic-bezier(0.4, 0, 0.2, 1)',
}

networkSelector (optional)​

Styles the network selector that cardNumber and card render for co-badged cards in manual mode, independently of the input fields. See Style the built-in selector for each token and its default.

networkSelector: {
panel: {
background: { color: '#ffffff' },
border: { color: '#d1d5db', radius: '12px' },
shadow: '0 4px 12px rgba(0, 0, 0, 0.12)',
},
option: {
text: { color: '#111827' },
hover: { background: { color: '#f3f4f6' } },
selected: { background: { color: '#2563eb' }, text: { color: '#ffffff' } },
},
}