Financing line
Or 4 interest-free payments under the price, with a modal showing the exact schedule and terms.
componentfinancinganyfinancing
$
npx shadcn@latest add @storekit/upsell-financingupsell-financing
Live and interactive. Switch the brand in the frame header to see the same source under another token file.
Installation
$
npx shadcn@latest add @storekit/upsell-financing$
pnpm dlx shadcn@latest add @storekit/upsell-financing$
yarn dlx shadcn@latest add @storekit/upsell-financing$
bunx --bun shadcn@latest add @storekit/upsell-financingSource
components/storekit/upsell/upsell-financing.tsx
199 lines
"use client"
import { useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { X } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { Price } from "@/components/storekit/ui/price"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Financing micro-offer. Price, then one muted line: "or 4 payments of $X".
"How it works" opens a modal with the real schedule (the last payment
absorbs rounding so the four add up to the price exactly) and the terms in
plain language. Hidden below the provider's minimum. Integer cents. */
const EASE = [0.23, 1, 0.32, 1] as const
const RING = "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
function schedule(total: number, n: number) {
const base = Math.floor(total / n)
return Array.from({ length: n }, (_, i) => (i === n - 1 ? total - base * (n - 1) : base))
}
export function UpsellFinancing({
price = 17900,
compareAt,
installments = 4,
intervalWeeks = 2,
provider = "Pay in 4",
minimum = 3500,
maximum = 150000,
terms = [
"0% interest and no fees when you pay on time.",
"A soft credit check at checkout. It does not affect your score.",
"Approval and late fees depend on the provider and your state.",
"Returns refund your remaining payments first.",
],
className,
}: {
price?: number
compareAt?: number
installments?: number
intervalWeeks?: number
/** Plain-text provider name shown in the line and modal. */
provider?: string
/** Outside this range the line is hidden, since the offer is not available. */
minimum?: number
maximum?: number
terms?: string[]
className?: string
}) {
const reduce = useReducedMotion()
const uid = useId()
const sk = useSkBlock("upsell", "upsell-financing")
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
const eligible = price >= minimum && price <= maximum
const parts = schedule(price, installments)
useEffect(() => {
if (!open) return
const prev = document.body.style.overflow
document.body.style.overflow = "hidden"
const id = window.requestAnimationFrame(() => closeRef.current?.focus())
return () => {
document.body.style.overflow = prev
window.cancelAnimationFrame(id)
}
}, [open])
const close = () => {
setOpen(false)
triggerRef.current?.focus()
}
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation()
close()
return
}
if (e.key !== "Tab" || !panelRef.current) return
const els = panelRef.current.querySelectorAll<HTMLElement>("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])")
if (!els.length) return
const first = els[0]
const last = els[els.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
return (
<div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex flex-col gap-1", className)}>
<Price amount={price} compareAt={compareAt} size="lg" showSavings />
{eligible ? (
<p className="flex flex-wrap items-baseline gap-x-1.5 text-[13px] text-sk-muted tabular-nums">
<span>
or {installments} interest-free payments of <span className="font-medium text-sk-ink">{money(parts[0])}</span> with {provider}
</span>
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
onClick={() => {
sk.emit("interact", "financing_details", price)
setOpen(true)
}}
className={cn("relative inline-flex min-h-[32px] items-center rounded-sm font-medium text-sk-ink underline decoration-sk-line underline-offset-4 transition-[text-decoration-color] duration-200 after:absolute after:-inset-x-1 after:-inset-y-1.5 after:content-[''] hover:decoration-sk-ink", RING)}
>
How it works
</button>
</p>
) : null}
<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-6" onKeyDown={onKeyDown}>
<motion.div
key="scrim"
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.2 }}
onClick={close}
className="absolute inset-0 bg-sk-ink/40"
/>
<motion.div
key="panel"
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${uid}-title`}
initial={{ opacity: 0, y: reduce ? 0 : 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : 12 }}
transition={{ duration: reduce ? 0 : 0.35, ease: EASE }}
className="relative flex max-h-[90dvh] w-full max-w-md flex-col gap-5 overflow-y-auto overscroll-contain rounded-t-sk-card border border-sk-line bg-sk-card p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-[0_24px_60px_-24px_rgb(var(--sk-ink)/0.25)] sm:rounded-sk-card sm:p-6"
>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{provider}</span>
<h2 id={`${uid}-title`} className="font-sk-display text-xl font-semibold leading-[1.1] tracking-[-0.02em] text-sk-ink tabular-nums">
{money(price)} in {installments} payments
</h2>
</div>
<button
ref={closeRef}
type="button"
aria-label="Close"
onClick={close}
className={cn("-mr-2 -mt-2 grid h-11 w-11 shrink-0 place-items-center rounded-sk-btn text-sk-muted transition-[color,background-color,transform] duration-200 hover:bg-sk-ink/[0.05] hover:text-sk-ink active:scale-[0.97]", RING)}
>
<X size={18} />
</button>
</div>
<ol className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{parts.map((amt, i) => (
<motion.li
key={i}
initial={{ opacity: 0, y: reduce ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0 : 0.4, ease: EASE, delay: reduce ? 0 : 0.08 + i * 0.05 }}
className="flex flex-col gap-1 rounded-sk-btn bg-sk-surface p-3 tabular-nums"
>
<span className="text-[12px] text-sk-muted">{i === 0 ? "Today" : `Week ${i * intervalWeeks}`}</span>
<span className="text-sm font-semibold text-sk-ink">{money(amt)}</span>
</motion.li>
))}
</ol>
<ul className="flex flex-col gap-2.5 border-t border-sk-line pt-4">
{terms.map((t) => (
<li key={t} className="flex gap-2.5 text-sm leading-[1.5] text-sk-muted">
<span aria-hidden className="mt-[9px] h-1 w-1 shrink-0 rounded-full bg-sk-muted" />
{t}
</li>
))}
</ul>
<Button variant="outline" className="w-full active:scale-[0.97]" onClick={close}>
Got it
</Button>
<p className="text-[12px] leading-[1.5] text-sk-muted">Choose {provider} at checkout. Payment options are offered by the provider, not by this store.</p>
</motion.div>
</div>
) : null}
</AnimatePresence>
</div>
)
}
Usage
Import from "@/components/storekit/upsell/upsell-financing" and pass your product data. The demo above exercises the full prop surface with sample values.
