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.
| Property | Type | Default | Description |
|---|---|---|---|
family | string | — | 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. |
src | string | 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. |
weight | string | '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
| Token | Type | Description |
|---|---|---|
colors.primary | string | Primary accent color (focus rings, active states) |
colors.error | string | Error state color |
colors.success | string | Success / complete state color |
colors.text.primary | string | Main input text color |
colors.text.secondary | string | Secondary text color |
colors.text.placeholder | string | Placeholder text color |
colors.text.disabled | string | Text color when element is disabled |
colors.background.default | string | Default background |
colors.background.hover | string | Background on hover |
colors.background.focus | string | Background when focused |
colors.background.disabled | string | Background when disabled |
colors.border.default | string | Default border color |
colors.border.hover | string | Border color on hover |
colors.border.focus | string | Border color when focused |
colors.border.error | string | Border color in error state |
typography
| Token | Type | Description |
|---|---|---|
typography.fontFamily | string | Font stack. Web fonts must also be passed in fonts. |
typography.fontSize.sm | string | Small font size |
typography.fontSize.base | string | Default font size |
typography.fontSize.lg | string | Large font size |
typography.fontWeight.normal | string | Normal weight |
typography.fontWeight.medium | string | Medium weight |
typography.fontWeight.semibold | string | Semibold weight |
typography.lineHeight.tight | string | Tight line height |
typography.lineHeight.normal | string | Normal line height |
typography.lineHeight.relaxed | string | Relaxed line height |
spacing
| Token | Description |
|---|---|
spacing.xs | Extra-small spacing |
spacing.sm | Small spacing |
spacing.md | Medium spacing |
spacing.lg | Large spacing |
spacing.xl | Extra-large spacing |
sizing
| Token | Type | Description |
|---|---|---|
sizing.input.height | string | Exact 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
| Token | Description |
|---|---|
borders.radius.none | No radius ('0') |
borders.radius.sm | Small radius |
borders.radius.base | Default radius |
borders.radius.md | Medium radius |
borders.radius.lg | Large radius |
borders.radius.full | Full / pill radius |
borders.width.thin | Thin border width |
borders.width.base | Default border width |
borders.width.thick | Thick 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' } },
},
}