shadimage
paddle
blockexpress-checkoutsource: ok · hit

Express checkout

Mobile-first checkout experience with pricing cards and sheet-style checkout drawer. Automatically resizes based on the height of the checkout.

npx shadcn add "https://developer.paddle.com/r/express-checkout.json"
free

Where it goes

pricingcheckout

Use cases

saas-landingecommerce

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
vaul

Registry dependencies

@paddle/paddle-client@paddle/pricing-select-cardsdrawerbutton

Files3 · source cached 10/14/2026, 2:31:05 PM

registry/new-york/blocks/express-checkout/components/express-checkout.tsx
"use client"

import * as React from "react"
import { PricingSelectCardStacked } from "@/registry/new-york/blocks/pricing-select-cards/components/pricing-select-card-stacked"
import { PricingSelectCardGrid } from "@/registry/new-york/blocks/pricing-select-cards/components/pricing-select-card-grid"
import { PricingSelectCardGroup } from "@/registry/new-york/blocks/pricing-select-cards/components/pricing-select-card-group"
import { usePaddlePrices } from "@/registry/new-york/blocks/paddle-client/lib/hooks/use-paddle-prices"
import { ExpressCheckoutDrawer } from "./express-checkout-drawer"
import { Button } from "@/registry/new-york/ui/button"
import type {
  CheckoutCustomer,
  Environments,
} from "@/registry/new-york/blocks/paddle-client/lib/paddle-sdk-types"
import type { CheckoutCompleteData } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-types"
import type { PricingSelectPlan } from "@/registry/new-york/blocks/pricing-select-cards/components/pricing-select-card"
import { cn } from "@/lib/utils"

/** Props for the `ExpressCheckout` component. */
export type ExpressCheckoutProps = {
  clientToken: string
  environment?: Environments
  plans: PricingSelectPlan[]
  defaultPriceId?: string
  layout?: "stacked" | "grid"
  customer?: CheckoutCustomer
  discountCode?: string
  discountId?: string
  customData?: Record<string, unknown>
  theme?: "light" | "dark"
  locale?: string
  showNonExpressPaymentMethods?: boolean
  onComplete?: (data: CheckoutCompleteData) => void
  onError?: (error: Error) => void
  submitLabel?: string
  className?: string
}

export function ExpressCheckout({
  clientToken,
  environment = "production",
  plans,
  defaultPriceId,
  layout = "stacked",
  customer,
  discountCode,
  discountId,
  customData,
  theme,
  locale,
  showNonExpressPaymentMethods,
  onComplete,
  onError,
  submitLabel = "Subscribe now",
  className,
}: ExpressCheckoutProps) {
  const [selectedPlan, setSelectedPlan] = React.useState(defaultPriceId || plans[0]?.priceId || "")
  const [drawerOpen, setDrawerOpen] = React.useState(false)
  const selectedPlanName = plans.find((p) => p.priceId === selectedPlan)?.name

  const { prices, loading, error } = usePaddlePrices({
    clientToken,
    environment,
    priceIds: plans.map((plan) => plan.priceId),
    countryCode: customer?.address?.countryCode,
  })

  if (error) {
    return (
      <div className="text-destructive rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm">
        Failed to load pricing: {error.message}
      </div>
    )
  }

  return (
    <div className={cn("w-full max-w-md space-y-6", className)}>
      <PricingSelectCardGroup value={selectedPlan} onValueChange={setSelectedPlan} layout={layout}>
        {plans.map((plan) =>
          layout === "grid" ? (
            <PricingSelectCardGrid
              key={plan.priceId}
              priceId={plan.priceId}
              name={plan.name}
              priceData={prices[plan.priceId]}
              badge={plan.badge}
              loading={loading}
            />
          ) : (
            <PricingSelectCardStacked
              key={plan.priceId}
              priceId={plan.priceId}
              name={plan.name}
              priceData={prices[plan.priceId]}
              description={plan.description}
              badge={plan.badge}
              icon={plan.icon}
              loading={loading}
            />
          )
        )}
      </PricingSelectCardGroup>

      <Button
        className="w-full"
        size="lg"
        onClick={() => setDrawerOpen(true)}
        disabled={loading || !selectedPlan}
        aria-label={selectedPlanName ? `${submitLabel} - ${selectedPlanName}` : submitLabel}
      >
        {submitLabel}
      </Button>

      <ExpressCheckoutDrawer
        open={drawerOpen}
        onOpenChange={setDrawerOpen}
        priceId={selectedPlan}
        clientToken={clientToken}
        environment={environment}
        customer={customer}
        discountCode={discountCode}
        discountId={discountId}
        customData={customData}
        theme={theme}
        locale={locale}
        showNonExpressPaymentMethods={showNonExpressPaymentMethods}
        onComplete={onComplete}
        onError={onError}
      />
    </div>
  )
}
registry/new-york/blocks/express-checkout/components/express-checkout-drawer.tsx
"use client"

import * as React from "react"
import {
  Drawer,
  DrawerContent,
  DrawerDescription,
  DrawerTitle,
} from "@/registry/new-york/ui/drawer"
import { Skeleton } from "@/registry/new-york/ui/skeleton"
import { useCheckoutDrawer } from "@/registry/new-york/blocks/express-checkout/hooks/use-checkout-drawer"
import type {
  OpenCheckoutOptions,
  CheckoutCustomer,
  Environments,
} from "@/registry/new-york/blocks/paddle-client/lib/paddle-sdk-types"
import type { CheckoutCompleteData } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-types"

/** Props for the `ExpressCheckoutDrawer` component. */
export type ExpressCheckoutDrawerProps = {
  open: boolean
  onOpenChange: (open: boolean) => void
  priceId: string
  clientToken: string
  environment?: Environments
  customer?: CheckoutCustomer
  discountCode?: string
  discountId?: string
  customData?: Record<string, unknown>
  theme?: "light" | "dark"
  locale?: string
  showNonExpressPaymentMethods?: boolean
  onComplete?: (data: CheckoutCompleteData) => void
  onError?: (error: Error) => void
}

export function ExpressCheckoutDrawer({
  open,
  onOpenChange,
  priceId,
  clientToken,
  environment = "production",
  customer,
  discountCode,
  discountId,
  customData,
  theme,
  locale,
  showNonExpressPaymentMethods,
  onComplete,
  onError,
}: ExpressCheckoutDrawerProps) {
  const { openCheckout, closeCheckout, checkoutHeight, isReady } = useCheckoutDrawer({
    clientToken,
    environment,
    theme,
    locale,
    showNonExpressPaymentMethods,
    onComplete,
    onError,
  })

  const [checkoutLoaded, setCheckoutLoaded] = React.useState(false)

  // Stable ref — effects re-run only when actual values change
  const stableCustomer = React.useMemo(
    () => customer,
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
      customer?.id,
      customer?.email,
      customer?.address?.id,
      customer?.address?.countryCode,
      customer?.address?.postalCode,
      customer?.address?.region,
      customer?.address?.city,
      customer?.address?.firstLine,
      customer?.business?.id,
      customer?.business?.name,
      customer?.business?.taxIdentifier,
    ]
  )

  const stableCustomData = React.useMemo(
    () => customData,
    // JSON.stringify is the most pragmatic deep-compare for Record<string, unknown>
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [JSON.stringify(customData)]
  )

  React.useEffect(() => {
    if (open && isReady && priceId) {
      setCheckoutLoaded(false)
      // Delay until drawer is fully mounted and visible
      const timeoutId = setTimeout(() => {
        const options: OpenCheckoutOptions = {
          priceId,
          ...(stableCustomer && { customer: stableCustomer }),
          ...(discountCode ? { discountCode } : discountId ? { discountId } : {}),
          ...(stableCustomData && { customData: stableCustomData }),
        }
        openCheckout(options)
        // Brief delay for Paddle to render before showing content
        setTimeout(() => setCheckoutLoaded(true), 500)
      }, 300)

      return () => clearTimeout(timeoutId)
    }
  }, [
    open,
    isReady,
    priceId,
    stableCustomer,
    discountCode,
    discountId,
    stableCustomData,
    openCheckout,
  ])

  React.useEffect(() => {
    if (!open) {
      closeCheckout()
      setCheckoutLoaded(false)
    }
  }, [open, closeCheckout])

  return (
    <Drawer open={open} onOpenChange={onOpenChange} shouldScaleBackground={false}>
      <DrawerContent className="max-h-[80vh]">
        {/* Hidden title and description for accessibility */}
        <DrawerTitle className="sr-only">Checkout</DrawerTitle>
        <DrawerDescription className="sr-only">
          Complete your purchase using the checkout form below.
        </DrawerDescription>
        {/* Inner container handles height transitions separately from Vaul's drawer animation */}
        <div
          style={{ height: checkoutHeight }}
          className="overflow-hidden transition-[height] duration-300 ease-out motion-reduce:transition-none motion-reduce:duration-0"
        >
          <div className="h-full overflow-y-auto p-4">
            {!checkoutLoaded && (
              <div className="flex flex-col items-center gap-4 p-4">
                <Skeleton className="h-10 w-full rounded-lg" />
                <Skeleton className="h-10 w-full rounded-lg" />
                <Skeleton className="h-10 w-3/4 rounded-lg" />
              </div>
            )}
            <div className="checkout-container w-full min-h-[180px]" />
          </div>
        </div>
      </DrawerContent>
    </Drawer>
  )
}
registry/new-york/blocks/express-checkout/hooks/use-checkout-drawer.tsx
"use client"

import { useEffect, useCallback, useRef, useState } from "react"
import type {
  PaddleEventData,
  CheckoutSettings,
} from "@/registry/new-york/blocks/paddle-client/lib/paddle-sdk-types"
import { addPaddleEventListener } from "@/registry/new-york/blocks/paddle-client/lib/paddle-instance"
import {
  useCheckout,
  type UseCheckoutArgs,
} from "@/registry/new-york/blocks/paddle-client/lib/hooks/use-checkout"

const INITIAL_HEIGHT = 220
const PADDING = 95 // Inflated due to Paddle iframe sizing bug, will be reduced to ~40 once fixed

// checkout.ping.size carries a height field not yet in @paddle/paddle-js types
type PaddlePingSizeEvent = PaddleEventData & { height?: number }

// Express checkout settings — passed explicitly to useCheckout to match the
// checkout-container target div rendered in ExpressCheckoutDrawer.
const EXPRESS_SETTINGS: CheckoutSettings = {
  displayMode: "inline",
  variant: "express",
  frameTarget: "checkout-container",
  frameInitialHeight: 180,
  frameStyle: "width: 100%; border: 0;",
}

export type UseCheckoutDrawerArgs = Omit<UseCheckoutArgs, "checkoutSettings">

export function useCheckoutDrawer(args: UseCheckoutDrawerArgs) {
  const { openCheckout, closeCheckout, isReady } = useCheckout({
    ...args,
    checkoutSettings: EXPRESS_SETTINGS,
  })

  const [checkoutHeight, setCheckoutHeight] = useState(INITIAL_HEIGHT)
  const heightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)

  // Debounce height updates for smooth drawer transitions
  const setHeightDebounced = useCallback((height: number) => {
    if (heightTimeoutRef.current) {
      clearTimeout(heightTimeoutRef.current)
    }
    heightTimeoutRef.current = setTimeout(() => {
      const maxHeight = window.innerHeight * 0.8
      setCheckoutHeight(Math.min(height + PADDING, maxHeight))
    }, 50)
  }, [])

  // Track drawer height via checkout.ping.size events
  useEffect(() => {
    const unsubscribe = addPaddleEventListener((event) => {
      const { type, height } = event as PaddlePingSizeEvent
      if (type === "checkout.ping.size" && height) {
        setHeightDebounced(height)
      }
    })

    return unsubscribe
  }, [setHeightDebounced])

  // Cleanup debounce on unmount
  useEffect(() => {
    return () => {
      if (heightTimeoutRef.current) {
        clearTimeout(heightTimeoutRef.current)
      }
    }
  }, [])

  return {
    openCheckout,
    closeCheckout,
    checkoutHeight,
    isReady,
  }
}

More from paddle

Browse all components in this library →