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-financing
upsell-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-financing

Source

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.

Financing line, StoreKit