HextaUI

Payment

The payment step of a checkout, with wallets, cards, PayPal and Pay in 4. Handles card validation, declines, 3-D Secure and slow banks.

Open in new tab

Payment is the last step of a checkout. Wrap the parts in Payment with the order total and an onPay callback, then place ExpressCheckout, PaymentMethods, BillingAddress, PayButton and PaymentStatus where they fit your layout. Payment owns the state between them, so the button label follows the chosen method: “Pay $123.45” for a card, “Continue to PayPal”, “Pay $30.87 today” for Pay in 4.

The card form detects the brand from the first digits and swaps its mark only when the brand changes. It groups digits as you type, 4-6-5 for American Express, writes the expiry as MM/YY with the slash added for you, and accepts “1/27”, “0127” and pasted numbers with spaces or dashes. Each field is checked on blur or once it’s complete, then live, so an error clears the moment it’s fixed. Focus moves from number to expiry to security code only when the field before it is complete, valid and the next one is empty.

onPay gets a typed request with the card details or saved card, the amount, the billing choice and a fresh idempotency key. Return nothing or { status: "succeeded" } to finish: the button shows a check, holds for 700 ms and calls onComplete. Return { status: "declined", code } and the error appears above the methods with copy written for that code, focus moves to it, the card stays filled in and “Try another card” clears it. Return { status: "requires_action" } to open the “Confirm with your bank” dialog, where renderChallenge mounts your 3-D Secure step.

While a payment runs the button can’t be pressed twice, the methods are inert, leaving the page asks for confirmation, and after three seconds the status line says “Confirming with your bank…”. Saved cards that have expired show as disabled, and declineMessage gives the same copy for your own error screens.

  1. Get HextaUI Pro

    One payment unlocks every block, including future ones. See pricing.

  2. Add the Pro registry to components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Add your token

    Create a token on your account page and put it in .env.local as HEXTAUI_PRO_TOKEN.

  4. Add the block

    pnpm dlx shadcn@latest add @hextaui-pro/payment

Stripe PaymentIntent

Create a PaymentIntent with the request’s idempotency key, confirm it with Stripe and return a declined result with Stripe’s decline code. In production, collect the card with Stripe Elements so raw card numbers never reach your server; the card form here is a visual reference that matches it.

"use client"

import { money } from "../price/money"
import { Payment, PayButton, PaymentStatus } from "@/components/blocks/payment/payment"
import { PaymentMethods } from "@/components/blocks/payment/payment-methods"
import type { PaymentRequest, PaymentResult } from "@/components/blocks/payment/payment-model"

type StripeConfirmResult = {
  error?: { code?: string; decline_code?: string; message?: string }
  paymentIntent?: {
    id: string
    status: "succeeded" | "processing" | "requires_action"
  }
}

type Stripe = {
  confirmPayment: (options: {
    elements: unknown
    clientSecret: string
    redirect: "if_required"
    confirmParams?: { return_url: string }
  }) => Promise<StripeConfirmResult>
}

async function createPaymentIntent(idempotencyKey: string) {
  const response = await fetch("/api/payment-intents", {
    method: "POST",
    headers: { "Idempotency-Key": idempotencyKey },
  })
  if (!response.ok) throw new Error("We couldn’t start the payment. Try again.")
  const { clientSecret } = (await response.json()) as { clientSecret: string }
  return clientSecret
}

export function StripePayment({
  stripe,
  elements,
}: {
  stripe: Stripe
  elements: unknown
}) {
  const pay = async (request: PaymentRequest): Promise<PaymentResult> => {
    const clientSecret = await createPaymentIntent(request.idempotencyKey)
    const { error, paymentIntent } = await stripe.confirmPayment({
      elements,
      clientSecret,
      redirect: "if_required",
      confirmParams: { return_url: `${window.location.origin}/order/complete` },
    })
    if (error) {
      return {
        status: "declined",
        code: error.decline_code ?? error.code,
        message: error.message,
      }
    }
    return { status: "succeeded", id: paymentIntent?.id }
  }

  return (
    <Payment
      total={money("123.45", "USD")}
      methods={["card"]}
      onPay={pay}
      onComplete={() => window.location.assign("/order/complete")}
    >
      <PaymentMethods />
      <PayButton />
      <PaymentStatus />
    </Payment>
  )
}

PayPal redirect

Create a PayPal order on your server and send the shopper to its approval URL. The button keeps its processing state while the page leaves, and PayPal-Request-Id stops a double order if the request is retried.

"use client"

import { money } from "../price/money"
import { Payment, PayButton, PaymentStatus } from "@/components/blocks/payment/payment"
import { PaymentMethods } from "@/components/blocks/payment/payment-methods"
import type { PaymentRequest } from "@/components/blocks/payment/payment-model"

export function PayPalRedirect({ cartId }: { cartId: string }) {
  const pay = async (request: PaymentRequest) => {
    if (request.method !== "paypal") {
      throw new Error("This store only takes PayPal right now.")
    }
    const response = await fetch("/api/paypal/orders", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "PayPal-Request-Id": request.idempotencyKey,
      },
      body: JSON.stringify({ cartId }),
    })
    if (!response.ok) {
      throw new Error("PayPal isn’t responding. Nothing was charged.")
    }
    const { approveUrl } = (await response.json()) as { approveUrl: string }
    window.location.assign(approveUrl)
    await new Promise(() => {})
  }

  return (
    <Payment
      total={money("123.45", "USD")}
      methods={["paypal"]}
      defaultMethod="paypal"
      onPay={pay}
    >
      <PaymentMethods />
      <PayButton />
      <PaymentStatus />
    </Payment>
  )
}

Shopify checkout handoff

Use the payment step as a branded front for Shopify’s checkout. Shop Pay opens checkoutUrl with payment=shop_pay, and PayPal hands off to the same URL.

"use client"

import { ExpressCheckout } from "@/components/blocks/payment/express-checkout"
import { Payment, PayButton, PaymentStatus } from "@/components/blocks/payment/payment"
import { PaymentMethods } from "@/components/blocks/payment/payment-methods"

type ShopifyCart = {
  checkoutUrl: string
  cost: { totalAmount: { amount: string; currencyCode: string } }
}

function go(url: string) {
  window.location.assign(url)
  return new Promise<void>(() => {})
}

export function ShopifyHandoff({ cart }: { cart: ShopifyCart }) {
  const url = new URL(cart.checkoutUrl)

  return (
    <Payment
      total={cart.cost.totalAmount}
      methods={["paypal"]}
      defaultMethod="paypal"
      onPay={() => go(url.toString())}
    >
      <ExpressCheckout
        methods={["shop_pay"]}
        onExpress={() => {
          url.searchParams.set("payment", "shop_pay")
          return go(url.toString())
        }}
      />
      <PaymentMethods />
      <PayButton />
      <PaymentStatus />
    </Payment>
  )
}

Anatomy

The parts you compose, from the outside in.

PartDescription
PaymentHolds the method, card fields, billing choice and payment state, and renders the bank dialog.
ExpressCheckoutA row of equal wallet buttons for the wallets you pass, with an “or pay another way” divider.
PaymentMethodsSaved cards, the card form, PayPal and Pay in 4 as radio cards, with the decline message above them.
BillingAddressThe “Billing address same as shipping” checkbox and the billing form it reveals.
PayButtonThe full-width pay button. Its label follows the method and it shows processing, waiting and paid.
PaymentStatusA line under the button with reassurance or progress, and the live region for screen readers.
declineMessage, PaymentDeclinedErrorCopy for each decline code, and an error you can throw from onPay with a code.
PropTypeDefault
totalThe amount to charge, shown on the button.
Money–
onPayRuns the payment. Throwing shows the error’s message at the payment section.
(request: PaymentRequest) => Promise<PaymentResult | void> | PaymentResult | void–
onCompleteCalled after the success check has held.
(request: PaymentRequest, result: PaymentResult) => void–
validateRuns before onPay. Return false to stop, for example when your billing form has errors.
(request: PaymentRequest) => boolean–
methodsMethods listed after any saved cards, in order.
("card" | "paypal" | "bnpl" | "apple_pay" | "google_pay")[]["card", "paypal", "bnpl"]
savedCardsCards on file. Expired ones are shown but can’t be picked.
SavedCard[]–
methodThe selected method, controlled. Saved cards use "saved:<id>".
string–
defaultMethodThe first usable saved card, or the first method.
string–
onMethodChangeCalled when the shopper picks another method.
(method: string) => void–
sameAsShippingWhether billing matches shipping, controlled.
boolean–
defaultSameAsShippingThe starting value of the billing checkbox.
booleantrue
onSameAsShippingChangeCalled when the billing checkbox changes.
(same: boolean) => void–
bnplHow many payments Pay in 4 splits into, and its name.
{ installments?: number; title?: string }{ installments: 4 }
renderChallengeContent of the “Confirm with your bank” dialog. Call complete with the final result.
(challenge: { action, amount, complete, cancel }) => ReactNode–
successDelayHow long the paid state holds before onComplete, in ms.
number700
slowAfterWhen the status line switches to “Confirming with your bank…”, in ms.
number3000
todayThe date expiry checks use. The browser’s date by default.
Date–
localeLocale for amounts.
Intl.LocalesArgument"en-US"
labelsEvery user-facing string.
Partial<PaymentLabels>–

ExpressCheckout

Works inside Payment or on its own.

PropTypeDefault
methodsWallets to show, in order. Only these render.
("apple_pay" | "google_pay" | "shop_pay" | "paypal")[]–
onExpressOpens the wallet. The button shows a spinner until it settles.
(method: ExpressMethod) => Promise<PaymentResult | void> | PaymentResult | void–
dividerShow the “or pay another way” divider.
booleantrue
labelsButton names, divider and group label.
Partial<ExpressCheckoutLabels>–
PropTypeDefault
billingAddressYour billing form, revealed when the checkbox is cleared. Its first field gets focus.
ReactNode–

PayButton

Also takes Button props. Children replace the automatic label.

PropTypeDefault
PropTypeDefault
methodWhat the shopper chose. card adds card, saved_card adds savedCard, bnpl adds installments, express adds express.
"card" | "saved_card" | "paypal" | "bnpl" | "apple_pay" | "google_pay" | "express"–
amountThe total.
Money–
idempotencyKeyNew for every attempt. Send it to your payment provider.
string–
billingSameAsShippingThe billing checkbox.
boolean–
PropTypeDefault
statusWhat happened.
"succeeded" | "requires_action" | "declined"–
codeinsufficient_funds, expired_card, incorrect_cvc, card_declined, processing_error or authentication_failed.
DeclineCode | string–
messageOverrides the copy for the code.
string–
KeyAction
↑↓Move between payment methods.
TabMove from the chosen method into its card fields.
EnterPay from any card field.
EscClose the bank dialog, which cancels that attempt.
  • Methods are a radio group, and each card field has a visible label, its own error message and autocomplete tokens (cc-number, cc-exp, cc-csc, cc-name) so browsers can fill them.
  • A decline is announced as an alert and takes focus, so screen reader users hear what went wrong and land next to “Try another card”.
  • Processing, waiting for the bank and success are announced through a polite live region. The detected brand is read with the card number field.
  • The button keeps its size through every state, inputs are 16px on touch screens, and with reduced motion the reveals and icon swaps change without moving.

Built with

The free HextaUI components Payment is made from. Each one installs on its own.

Code

8 files, added to components/blocks/payment.