Add-on checkbox
One optional add-on row: warranty, refill, sample or packaging. Unchecked by default.
componentaddonanyaddon
$
npx shadcn@latest add @storekit/upsell-addon-checkboxupsell-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-checkboxSource
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.