How to customize the checkout's appearance

This guide shows you how to style the pre-built card form, payment buttons, drop-in, and individual collect elements with the appearance option, how to change their labels and placeholders, and how to load custom fonts.
It assumes you have already initialized the SDK and hold a payrails client instance - see
the setup guide if you have not.

1. The base stylesheet

The pre-built components (drop-in, card form, buttons, card list) ship a base stylesheet. It is injected automatically when the SDK loads - no manual import is needed.

Everything below customizes on top of these base styles.

2. The appearance option

Every element the SDK draws accepts an appearance object. Its rules field is CSS-like key/value: selectors on the outside, CSS declarations on the inside:

const paymentButton = payrails.paymentButton({
  appearance: {
    rules: {
      '.payrails-button': { backgroundColor: '#1a1a1a', color: '#fff' },
      '.payrails-button:hover': { backgroundColor: '#333' },
      '.payrails-button--disabled': { opacity: '0.5' },
    },
  },
});

Selectors target stable class names the SDK guarantees on the DOM:
.payrails-input, .payrails-button, .payrails-dropdown, .payrails-label, .payrails-tile, .payrails-container, .payrails-row, .payrails-cell, .payrails-icon, .payrails-checkbox, .payrails-error, .payrails-text.

State variants use BEM modifiers on those classes:

StateClass
Field with invalid input.payrails-input--invalid
Field with valid input.payrails-input--valid
Field is empty.payrails-input--empty
Field touched since load.payrails-input--dirty
Button in loading state.payrails-button--loading
Button disabled.payrails-button--disabled
Checkbox checked.payrails-checkbox--checked
Brand tile selected.payrails-tile--selected

Any selector a browser supports is valid inside rules — pseudo-classes and pseudo-elements (:hover, :focus, :focus-visible, ::placeholder, ::selection), media queries (@media), and feature queries (@supports).

The --invalid and --valid classes clear while a field is focused, so a field being corrected does not show the error state. For a persistent invalid look on touched fields, key off --dirty:

rules: {
  '.payrails-input--dirty:not(:focus).payrails-input--invalid': {
    borderColor: '#dc2626',
  },
}

Rules are scoped to the element instance that declares them — a rule passed to one card form does not leak into another widget. SDK defaults live in the CSS layer @layer payrails-defaults; your rules land in
@layer payrails-appearance, which always wins over the defaults.

3. Style the card form

payrails.cardForm(options) accepts a CardFormAppearance — root rules for the form itself plus one slot per nested widget (installments dropdown, address selector, brand selector):

const cardForm = payrails.cardForm({
  appearance: {
    rules: {
      '.payrails-container': { maxWidth: '480px', gap: '16px' },
      '.payrails-input': {
        border: '1px solid #eae8ee',
        borderRadius: '4px',
        color: '#1d1d1d',
      },
      '.payrails-input:focus': { borderColor: '#6b7cff' },
      '.payrails-input--invalid': {
        color: '#f44336',
        backgroundColor: '#fdeaea',
      },
      '.payrails-label': { fontSize: '12px', fontWeight: 'bold' },
      '.payrails-error': { color: '#f44336' },
    },
    installments: {
      rules: { '.payrails-dropdown': { borderRadius: '8px' } },
    },
    address: {
      rules: { '.payrails-dropdown, .payrails-input': { borderRadius: '8px' } },
    },
    brandSelector: {
      rules: { '.payrails-tile--selected': { borderColor: '#4F46E5' } },
    },
  },
});

cardForm.mount('#card-form');

The card payment button is a sibling of the card form, not a child — pass its appearance to payrails.paymentButton({ appearance }) directly, or to the cardPaymentButton slot of the drop-in's appearance.

4. Style the drop-in

The drop-in's appearance is keyed by its building blocks; each slot takes the same shape as the standalone element:

const dropin = payrails.dropin({
  appearance: {
    rules: {
      '.payrails-container': { borderRadius: '12px' },
    },
    cardForm: {
      rules: { '.payrails-input': { border: '1px solid #ddd' } },
    },
    cardPaymentButton: {
      rules: { '.payrails-button': { backgroundColor: '#4F46E5' } },
    },
    loadingScreen: { rules: {} },
    authSuccess: { rules: {} },
    authFailed: { rules: {} },
  },
});

5. Provider-drawn wallet buttons keep their own options

Google Pay and PayPal draw their own chrome (a native SDK button, a hosted iframe), so CSS rules can't reach them. They take structured appearance.settings instead:

// Google Pay
const googlePayButton = payrails.googlePayButton({
  appearance: {
    settings: {
      buttonColor: 'black', // per Google Pay ButtonColor
      buttonType: 'buy', // per Google Pay ButtonType
      buttonSizeMode: 'fill',
      locale: 'de',
    },
  },
});

// PayPal
const paypalButton = payrails.paypalButton({
  appearance: {
    settings: {
      color: 'gold', // 'gold' | 'blue' | 'silver' | 'white' | 'black'
      shape: 'rect', // 'rect' | 'pill'
      label: 'paypal',
      height: 40,
      tagline: false,
      locale: 'de_DE',
    },
  },
});

6. Change labels, placeholders, and error messages

Text on the card form is controlled through translations:

const cardForm = payrails.cardForm({
  showStoreInstrumentCheckbox: true,
  translations: {
    placeholders: {
      CARD_NUMBER: '1234 1234 1234 1234',
      CVV: 'CVC',
    },
    labels: {
      CARD_NUMBER: 'Card number',
      CVV: 'Security code',
      storeInstrument: 'Save this card for next time',
    },
    error: {
      default: {
        CARD_NUMBER: 'Please check the card number',
      },
    },
  },
});
  • placeholders and labels are keyed by field type (CARD_NUMBER, CARDHOLDER_NAME, CVV, EXPIRATION_MONTH, EXPIRATION_YEAR, EXPIRATION_DATE).
  • labels.storeInstrument renames the save-card checkbox label.
  • error.default sets the default validation error message per field type.

The payment button's label is translations.label on payrails.paymentButton(...).

7. Load custom fonts

The secure fields render inside iframes, so fonts from your page are not automatically available there. Pass font descriptors via fonts — either a CSS source or an explicit font-face definition:

const cardForm = payrails.cardForm({
  fonts: [
    {
      family: 'Inter',
      src: "url('https://example.com/fonts/inter.woff2')",
      weight: 400,
      style: 'normal',
    },
  ],
  appearance: {
    rules: {
      '.payrails-input': { fontFamily: 'Inter, sans-serif' },
    },
  },
});

A font descriptor accepts family, src, style, weight, unicodeRange, display, or a cssSrc URL. The same fonts option is accepted by payrails.collectContainer(...).

8. Style individual collect elements

When you build your own form with a collect container (see How to collect card data with secure fields),
each element accepts its own appearance; the rules are applied inside that field's secure iframe:

const cardNumber = container.createCollectElement({
  type: ElementType.CARD_NUMBER,
  appearance: {
    rules: {
      '.payrails-input': {
        border: '1px solid #eae8ee',
        padding: '10px 16px',
        borderRadius: '4px',
        color: '#1d1d1d',
      },
      '.payrails-input--valid': { color: '#4caf50' },
      '.payrails-input--invalid': { color: '#f44336' },
      '.payrails-label': { fontSize: '12px', fontWeight: 'bold' },
      '.payrails-error': { color: '#f44336' },
    },
  },
});

Only the root rules are forwarded to the iframe — secure fields have no nested widget slots.

9. Update appearance and texts after mounting

The card form can be restyled and re-labeled at runtime without remounting:

cardForm.update({
  appearance: {
    rules: { '.payrails-input': { borderColor: '#6b7cff' } },
  },
  translations: {
    labels: { CARD_NUMBER: 'Kartennummer' },
  },
});

Updates accumulate: a partial appearance merges over the current rules, leaving other selectors and nested widget slots (installments, address, brandSelector) intact. To clear a declaration, pass its key with an empty value; clearing a whole selector requires a remount.

The card payment button supports paymentButton.update({ translations }) for its label — its appearance cannot be changed after mounting.


Did this page help you?