React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

stripePaymentElementCardElementAppearance APIstyling

Payment Element & Card Element

Recipe

Use PaymentElement for a unified payment input that supports cards, wallets, and bank transfers. Use CardElement when you only need card payments and want finer field-level control. Style both using Stripe's Appearance API.

PaymentElement (recommended for most cases):

// app/checkout/payment-form.tsx
"use client";
 
import { PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { useState, type FormEvent } from "react";
 
export function PaymentForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [error, setError] = useState<string | null>(null);
  const [processing, setProcessing] = useState(false);
 
  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!stripe || !elements) return;
 
    setProcessing(true);
    const { error: submitError } = await elements.submit();
    if (submitError) {
      setError(submitError.message ?? "Validation error");
      setProcessing(false);
      return;
    }
 
    const { error: confirmError } = await stripe.confirmPayment({
      elements,
      confirmParams: { return_url: `${window.location.origin}/success` },
    });
 
    if (confirmError) {
      setError(confirmError.message ?? "Payment failed");
      setProcessing(false);
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      {error && <p className="text-red-500 mt-2">{error}</p>}
      <button type="submit" disabled={!stripe || processing}>
        {processing ? "Processing..." : "Pay"}
      </button>
    </form>
  );
}

CardElement (card-only):

"use client";
 
import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { useState, type FormEvent } from "react";
 
export function CardForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [error, setError] = useState<string | null>(null);
 
  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!stripe || !elements) return;
 
    const cardElement = elements.getElement(CardElement);
    if (!cardElement) return;
 
    const { error, paymentMethod } = await stripe.createPaymentMethod({
      type: "card",
      card: cardElement,
    });
 
    if (error) {
      setError(error.message ?? "Error");
    } else {
      // Send paymentMethod.id to your server
      console.log("PaymentMethod:", paymentMethod.id);
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <CardElement options={{ style: { base: { fontSize: "16px" } } }} />
      {error && <p className="text-red-500 mt-2">{error}</p>}
      <button type="submit" disabled={!stripe}>Pay</button>
    </form>
  );
}

Working Example

A styled payment form that matches a dark-themed app:

// app/checkout/styled-checkout.tsx
"use client";
 
import { useEffect, useState } from "react";
import { Elements } from "@stripe/react-stripe-js";
import type { Appearance } from "@stripe/stripe-js";
import { stripePromise } from "@/lib/stripe-client";
import { createPaymentIntent } from "@/app/actions/payment";
import { PaymentForm } from "./payment-form";
 
const appearance: Appearance = {
  theme: "night",
  variables: {
    colorPrimary: "#6366f1",
    colorBackground: "#1e1e2e",
    colorText: "#e2e8f0",
    colorDanger: "#ef4444",
    fontFamily: "Inter, system-ui, sans-serif",
    borderRadius: "8px",
    spacingUnit: "4px",
  },
  rules: {
    ".Input": {
      border: "1px solid #334155",
      boxShadow: "none",
      padding: "12px",
    },
    ".Input:focus": {
      border: "1px solid #6366f1",
      boxShadow: "0 0 0 1px #6366f1",
    },
    ".Label": {
      fontWeight: "500",
      fontSize: "14px",
    },
    ".Tab": {
      border: "1px solid #334155",
      borderRadius: "8px",
    },
    ".Tab--selected": {
      backgroundColor: "#6366f1",
      borderColor: "#6366f1",
    },
  },
};
 
export default function StyledCheckout() {
  const [clientSecret, setClientSecret] = useState<string | null>(null);
 
  useEffect(() => {
    createPaymentIntent(4999).then(({ clientSecret }) => {
      setClientSecret(clientSecret);
    });
  }, []);
 
  if (!clientSecret) return <div className="text-white">Loading...</div>;
 
  return (
    <div className="max-w-lg mx-auto p-8 bg-[#1e1e2e] rounded-xl">
      <h2 className="text-xl font-bold text-white mb-6">Complete Payment</h2>
      <Elements
        stripe={stripePromise}
        options={{
          clientSecret,
          appearance,
          layout: {
            type: "tabs",
            defaultCollapsed: false,
          },
        }}
      >
        <PaymentForm />
      </Elements>
    </div>
  );
}

Deep Dive

How It Works

  • PaymentElement is a single component that dynamically renders input fields for all enabled payment methods (cards, Apple Pay, Google Pay, bank debits, etc.). Stripe determines which methods to show based on the transaction's currency, amount, and the customer's location.
  • CardElement renders a single-line card input with number, expiry, and CVC fields combined. It only supports card payments.
  • The Appearance API controls styling through theme, variables, and rules. Themes (stripe, night, flat) provide a base; variables override design tokens; rules target specific CSS-like selectors for individual elements.
  • Layout options for PaymentElement control how payment methods are displayed: "tabs" shows them as selectable tabs, "accordion" shows them as expandable sections, and "auto" lets Stripe decide.
  • Billing details collection can be configured per field. By default, PaymentElement collects the fields required by the selected payment method.

Variations

Accordion layout:

<Elements
  stripe={stripePromise}
  options={{
    clientSecret,
    layout: { type: "accordion", defaultCollapsed: false, radios: true },
  }}
>
  <PaymentForm />
</Elements>

Control billing details collection:

<PaymentElement
  options={{
    layout: "tabs",
    fields: {
      billingDetails: {
        name: "auto",
        email: "never",    // collect email yourself
        phone: "never",
        address: "auto",
      },
    },
  }}
/>

Split card fields (CardNumber, CardExpiry, CardCvc):

import {
  CardNumberElement,
  CardExpiryElement,
  CardCvcElement,
} from "@stripe/react-stripe-js";
 
function SplitCardForm() {
  return (
    <div className="space-y-4">
      <div>
        <label>Card Number</label>
        <CardNumberElement />
      </div>
      <div className="flex gap-4">
        <div className="flex-1">
          <label>Expiry</label>
          <CardExpiryElement />
        </div>
        <div className="flex-1">
          <label>CVC</label>
          <CardCvcElement />
        </div>
      </div>
    </div>
  );
}

TypeScript Notes

  • Import Appearance from @stripe/stripe-js to type your appearance configuration.
  • PaymentElement accepts PaymentElementProps which includes an options prop typed as PaymentElementOptions.
  • CardElement options use CardElementOptions with a different styling API (style.base, style.invalid, etc.) than the Appearance API.
import type { Appearance, LayoutObject } from "@stripe/stripe-js";
 
const layout: LayoutObject = {
  type: "tabs",
  defaultCollapsed: false,
};

Gotchas

  • PaymentElement requires a clientSecret on the Elements provider. You cannot use it without a PaymentIntent or SetupIntent.
  • CardElement uses a different styling system (style prop) than PaymentElement (Appearance API). They are not interchangeable.
  • Do not mount both PaymentElement and CardElement in the same Elements provider. Choose one.
  • The Appearance API's rules use Stripe's custom selectors (.Input, .Label, .Tab), not standard CSS selectors. Check the docs for the full list.
  • PaymentElement shows payment methods based on the PaymentIntent's configuration. If you only see cards, check that automatic_payment_methods is enabled and your Stripe Dashboard has other methods configured.
  • Fonts loaded via @font-face in your CSS are not automatically available inside Stripe Elements. Pass them via the fonts option on Elements.

Alternatives

ApproachProsCons
PaymentElementAll payment methods, single component, modernRequires clientSecret, less granular control
CardElementSimple, single-line card inputCards only, older styling API
Split card fieldsFull layout control over each fieldMore markup, cards only
Stripe CheckoutZero UI codeLeaves your site

FAQs

What is the main difference between PaymentElement and CardElement?
  • PaymentElement is a unified component that supports cards, wallets, bank transfers, and 40+ payment methods in one component
  • CardElement is a single-line input that only supports card payments
  • PaymentElement is recommended for most new integrations
What are the available layout options for PaymentElement?
  • "tabs" -- payment methods shown as selectable tabs
  • "accordion" -- payment methods shown as expandable sections
  • "auto" -- Stripe decides the best layout
How does the Appearance API work for styling Stripe Elements?
  • Choose a base theme ("stripe", "night", "flat")
  • Override design tokens via variables (colors, fonts, border radius, spacing)
  • Target specific elements via rules using Stripe's custom selectors (.Input, .Label, .Tab)
Gotcha: Can you mount both PaymentElement and CardElement in the same Elements provider?

No. You must choose one or the other within a single <Elements> provider. Mounting both causes conflicts and undefined behavior.

How do you control which billing details PaymentElement collects?
<PaymentElement
  options={{
    fields: {
      billingDetails: {
        name: "auto",
        email: "never",
        phone: "never",
        address: "auto",
      },
    },
  }}
/>

Set a field to "never" if you collect it yourself in your own form.

What are the split card field components and when should you use them?
  • CardNumberElement, CardExpiryElement, CardCvcElement
  • Use them when you need full layout control over individual card fields
  • They only support card payments, not other payment methods
Why might PaymentElement only show card payments even with automatic_payment_methods enabled?

Check your Stripe Dashboard payment method settings. automatic_payment_methods shows methods that are both enabled in the Dashboard and applicable to the transaction's currency and customer location.

Gotcha: Why don't custom fonts from your CSS work inside Stripe Elements?

Stripe Elements render inside iframes and cannot access fonts loaded via @font-face in your CSS. You must pass fonts explicitly via the fonts option on the <Elements> component.

TypeScript: How do you type an Appearance configuration object?
import type { Appearance, LayoutObject } from "@stripe/stripe-js";
 
const appearance: Appearance = {
  theme: "night",
  variables: { colorPrimary: "#6366f1" },
};
const layout: LayoutObject = { type: "tabs" };
What is the difference between CardElement's styling system and the Appearance API?
  • CardElement uses a style prop with base, invalid, and complete states
  • PaymentElement uses the Appearance API with theme, variables, and rules
  • The two systems are not interchangeable
TypeScript: What type does the PaymentElement options prop accept?

It accepts PaymentElementOptions from @stripe/stripe-js, which includes layout, fields, terms, wallets, and other configuration. Import it for type safety when building dynamic configurations.

Does PaymentElement require a clientSecret on the Elements provider?

Yes. Unlike CardElement, PaymentElement requires a clientSecret from a PaymentIntent or SetupIntent passed via the options prop on <Elements>. Without it, PaymentElement will not render.