Add-on checkbox

One optional add-on row: warranty, refill, sample or packaging. Unchecked by default.

componentaddonanyaddon
$npx shadcn@latest add @storekit/upsell-addon-checkbox
upsell-addon-checkbox

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-addon-checkbox
$pnpm dlx shadcn@latest add @storekit/upsell-addon-checkbox
$yarn dlx shadcn@latest add @storekit/upsell-addon-checkbox
$bunx --bun shadcn@latest add @storekit/upsell-addon-checkbox

Source

components/storekit/upsell/upsell-addon-checkbox.tsx
97 lines
"use client"

import { useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* One optional add-on as a single checkbox row: a warranty, a refill, a
   sample, gift packaging. The whole row is the hit target. Starts unchecked
   by default, because a pre-ticked add-on is a charge the shopper did not
   choose. Price is 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"

export function UpsellAddonCheckbox({
  title = "Add an electrolytes travel pack",
  description = "10 sticks for the gym bag. Ships in the same box.",
  price = 900,
  compareAt,
  image,
  checked: controlled,
  defaultChecked = false,
  onChange,
  className,
}: {
  title?: string
  description?: string
  /** Add-on price in integer cents. 0 renders "Free". */
  price?: number
  compareAt?: number
  image?: string
  checked?: boolean
  defaultChecked?: boolean
  onChange?: (checked: boolean) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("upsell", "upsell-addon-checkbox")
  const [inner, setInner] = useState(defaultChecked)
  const on = controlled ?? inner

  const toggle = () => {
    const next = !on
    sk.emit("interact", next ? "addon_on" : "addon_off", price)
    if (controlled === undefined) setInner(next)
    onChange?.(next)
  }

  return (
    <button
      {...sk.attrs}
      ref={sk.ref as React.RefObject<HTMLButtonElement>}
      type="button"
      role="checkbox"
      aria-checked={on}
      onClick={toggle}
      className={cn(
        "flex min-h-[64px] w-full items-center gap-3 rounded-sk-card border p-3 text-left transition-[border-color,background-color,transform] duration-200 active:scale-[0.97] sm:p-4",
        on ? "border-sk-accent bg-sk-accent/[0.05]" : "border-sk-line border-dashed bg-sk-card hover:border-sk-ink/30",
        RING,
        className
      )}
    >
      <span
        aria-hidden
        className={cn(
          "grid h-5 w-5 shrink-0 place-items-center rounded-[6px] border transition-[background-color,border-color] duration-200",
          on ? "border-sk-accent bg-sk-accent text-sk-accent-ink" : "border-sk-line bg-sk-card"
        )}
      >
        <svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none">
          <motion.path
            d="M3.5 8.5l3 3 6-7"
            stroke="currentColor"
            strokeWidth={2.25}
            strokeLinecap="round"
            strokeLinejoin="round"
            initial={false}
            animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}
            transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}
          />
        </svg>
      </span>
      {image ? <img src={image} alt="" className="h-11 w-11 shrink-0 rounded-sk-btn bg-sk-surface object-cover" /> : null}
      <span className="flex min-w-0 flex-1 flex-col gap-0.5">
        <span className="text-sm font-medium text-sk-ink">{title}</span>
        {description ? <span className="text-[13px] leading-[1.5] text-sk-muted">{description}</span> : null}
      </span>
      <span className="flex shrink-0 flex-col items-end tabular-nums">
        <span className="text-sm font-semibold text-sk-price">{price === 0 ? "Free" : `+${money(price)}`}</span>
        {compareAt && compareAt > price ? <s className="text-[12px] text-sk-muted">{money(compareAt)}</s> : null}
      </span>
    </button>
  )
}

Usage

Import from "@/components/storekit/upsell/upsell-addon-checkbox" and pass your product data. The demo above exercises the full prop surface with sample values.

Add-on checkbox, StoreKit