Pricing Receipt
NewInstallation
npx shadcn@latest add https://uibrik.com/r/pricing-receipt.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motionAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/pricing-receipt/pricing-receipt.tsx"use client"; import { useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "@/lib/utils"; import { PLANS, pickPlans, yearlySaving, type Plan, type Tiers } from "../_pricing/plans"; import { BillingToggle, EASE_OUT, FOCUS, Money, Motion, PANEL, PlanButton, SHELL, formatMoney, type Billing, type MoneyFormat, } from "../_pricing/parts"; // Pricing receipt. Plans on the left, a little printer on the right. Pick a // plan or flip the billing and the old slip is pulled back into the slot and // a new one prints out with the line items: the plan, the months, the yearly // discount, the total due today. Once it's out, a stamp marks it. The price // stops being a number on a card and becomes what you'd actually pay. // // Slips never queue: the old one is whisked up while the new one starts // printing, so clicking through plans quickly always shows the latest. export type PricingReceiptProps = MoneyFormat & { tiers?: Tiers; /** Plan picked on mount, by name. Defaults to the recommended plan. */ defaultPlan?: string; defaultBilling?: Billing; /** Word on the stamp. */ stamp?: string; /** Seconds for a slip to print. */ printDuration?: number; plans?: readonly Plan[]; onSelect?: (plan: Plan, billing: Billing) => void; className?: string; }; const SLIP_H = 216; export function PricingReceipt({ tiers = "3", defaultPlan, defaultBilling = "yearly", stamp = "Selected", printDuration = 0.7, plans = PLANS, currency = "USD", locale = "en-US", onSelect, className, }: PricingReceiptProps) { const shown = pickPlans(tiers, plans); const initial = shown.find((p) => p.name === defaultPlan) ?? shown.find((p) => p.featured) ?? shown[0]; const [name, setName] = useState(initial.name); const [billing, setBilling] = useState<Billing>(defaultBilling); const index = Math.max(0, shown.findIndex((p) => p.name === name)); const plan = shown[index]; const yearly = billing === "yearly"; const money = { currency, locale }; return ( <Motion> <section className={cn("@container w-full max-w-4xl", className)}> <div className="grid gap-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(0,22rem)]"> {/* plans */} <div className={cn(SHELL, "flex flex-col")}> <div className="flex min-h-11 flex-wrap items-center justify-between gap-2 pb-1.5 pl-3"> <h3 className="text-sm font-medium text-foreground">Choose a plan</h3> <BillingToggle value={billing} onChange={setBilling} saving={yearlySaving(shown)} /> </div> <div role="radiogroup" aria-label="Plan" className={cn(PANEL, "flex flex-1 flex-col gap-1 p-1.5")}> {shown.map((p) => { const on = p.name === plan.name; return ( <button key={p.name} type="button" role="radio" aria-checked={on} onClick={() => setName(p.name)} className={cn( "relative flex flex-1 items-center gap-4 rounded-xl px-4 py-4 text-left transition-colors duration-150", on ? "edge-tile bg-muted" : "hover:bg-muted/60", FOCUS, )} > <span aria-hidden className={cn( "grid size-4 shrink-0 place-items-center rounded-full transition-colors duration-150", on ? "bg-foreground" : "shadow-[inset_0_0_0_1.5px_var(--border)]", )} > <motion.span className="size-1.5 rounded-full bg-background" initial={false} animate={{ scale: on ? 1 : 0 }} transition={{ duration: 0.2, ease: EASE_OUT }} /> </span> <span className="flex min-w-0 flex-1 flex-col gap-0.5"> <span className="flex items-center gap-2 text-sm font-medium text-foreground"> {p.name} {p.featured && <span className="text-xs font-normal text-muted-foreground">Popular</span>} </span> <span className="truncate text-sm text-muted-foreground">{p.blurb}</span> </span> {p.monthly === 0 ? ( <span className="shrink-0 text-lg font-semibold text-foreground">Free</span> ) : ( <span className="flex shrink-0 items-baseline gap-1"> <Money value={yearly ? p.yearly : p.monthly} {...money} className="text-lg font-semibold text-foreground" /> <span className="text-xs text-muted-foreground">/ mo</span> </span> )} </button> ); })} </div> </div> {/* printer */} <div className={cn(SHELL, "flex flex-col")}> <div className="flex min-h-11 items-center pl-3"> <h3 className="text-sm font-medium text-foreground">Summary</h3> </div> <div className={cn(PANEL, "flex flex-1 flex-col p-4")}> {/* the slot the slip comes out of */} <div aria-hidden className="relative z-10 h-2 rounded-full bg-zinc-200 shadow-[inset_0_1px_2px_rgb(0_0_0/0.15)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_2px_rgb(0_0_0/0.6)]" /> <div className="relative -mt-1 overflow-hidden px-2" style={{ height: SLIP_H }} aria-live="polite"> <AnimatePresence initial={false}> <Slip key={`${plan.name}-${billing}`} plan={plan} number={index + 1} yearly={yearly} money={money} stamp={stamp} duration={printDuration} /> </AnimatePresence> </div> <PlanButton primary className="mt-4" onClick={() => onSelect?.(plan, billing)}> Continue with {plan.name} </PlanButton> </div> </div> </div> </section> </Motion> ); } // Zigzag tear along the bottom of the slip. const TEAR = "linear-gradient(#000 0 0) top / 100% calc(100% - 8px) no-repeat, conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) bottom / 12px 8px repeat-x"; function Slip({ plan, number, yearly, money, stamp, duration, }: { plan: Plan; number: number; yearly: boolean; money: MoneyFormat; stamp: string; duration: number; }) { const free = plan.monthly === 0; const lines: [string, number][] = free ? [[`${plan.name} plan`, 0]] : yearly ? [ [`${plan.name}, 12 months`, plan.monthly * 12], ["Yearly discount", (plan.yearly - plan.monthly) * 12], ] : [[`${plan.name}, monthly`, plan.monthly]]; const total = lines.reduce((s, [, n]) => s + n, 0); const date = new Date().toLocaleDateString(money.locale, { month: "short", day: "numeric", year: "numeric" }); return ( <motion.div className="absolute inset-x-2 top-0 bg-muted px-4 pb-6 pt-4 font-mono text-[13px] leading-5 text-foreground" style={{ mask: TEAR, WebkitMask: TEAR }} initial={{ y: "-100%" }} // starts as the old slip is halfway gone, so the two never sit on top of each other animate={{ y: 0, transition: { duration, delay: 0.1, ease: [0.4, 0, 0.2, 1] } }} exit={{ y: "-100%", transition: { duration: 0.18, ease: [0.4, 0, 1, 1] } }} > <div className="flex justify-between text-muted-foreground"> <span>RECEIPT</span> <span>#{String(number).padStart(4, "0")}</span> </div> <div className="text-muted-foreground" suppressHydrationWarning> {date} </div> <Rule /> {lines.map(([label, n]) => ( <div key={label} className="flex justify-between gap-3"> <span className="truncate">{label}</span> <span className="tabular-nums">{n < 0 ? `−${formatMoney(-n, money, 2)}` : formatMoney(n, money, 2)}</span> </div> ))} <Rule /> <div className="flex justify-between font-semibold"> <span>Total today</span> <span className="tabular-nums">{formatMoney(total, money, 2)}</span> </div> <div className="text-muted-foreground">{free ? "Free forever" : yearly ? `Renews in 12 months` : "Renews monthly"}</div> <motion.span aria-label={stamp} className="absolute bottom-6 right-4 rounded-md px-2 py-0.5 text-xs font-semibold uppercase tracking-widest text-emerald-600 shadow-[inset_0_0_0_1.5px_currentColor] dark:text-emerald-400" initial={{ opacity: 0, scale: 1.6, rotate: -14 }} animate={{ opacity: 0.9, scale: 1, rotate: -8 }} transition={{ duration: 0.18, delay: duration + 0.15, ease: [0.4, 0, 1, 1] }} > {stamp} </motion.span> </motion.div> ); } function Rule() { return <div aria-hidden className="my-2.5 border-t border-dashed border-zinc-300 dark:border-zinc-700" />; }
Adds the files to
components/uibrik/pricing-receipt and installs @number-flow/react, @tabler/icons-react and motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/pricing-receipt.Props
PricingReceipt- tiers
- Type "2" | "3"
- "3"
- How many plans to show.
- defaultBilling
- Type "monthly" | "yearly"
- "yearly"
- Billing period on mount.
- stamp
- Type string
- "Selected"
- Word on the stamp.
- printDuration
- Type number
- 0.7
- Time for a slip to print.
- currency
- Type string
- "USD"
- ISO currency code for the prices.
- defaultPlan
- Type string
- –
- Plan picked on mount, by name. Defaults to the recommended plan.
- plans
- Type Plan[]
- –
- Plans in tier order.
- onSelect
- Type (plan: Plan, billing: Billing) => void
- –
- Called when the continue button is pressed.
- className
- Type string
- –
- Classes for the section.