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 · lightvaul
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,
}
}