Sticky add to cart
Bottom bar that slides up once the buy button scrolls away, with thumb, variant, price and add.
npx shadcn@latest add @storekit/pdp-sticky-atcLive and interactive. Switch the brand in the frame header to see the same source under another token file.
Installation
npx shadcn@latest add @storekit/pdp-sticky-atcpnpm dlx shadcn@latest add @storekit/pdp-sticky-atcyarn dlx shadcn@latest add @storekit/pdp-sticky-atcbunx --bun shadcn@latest add @storekit/pdp-sticky-atcSource
"use client"
import { useEffect, useRef, useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { Check } from "lucide-react"
import { cn } 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"
/* Sticky add-to-cart bar. Slides up once the main buy button scrolls away.
`position="absolute"` pins it inside a scroll frame instead of the window.
The bar stays mounted and slides off screen when hidden (inert), so the
view event only fires once it is actually on screen. */
const EASE = [0.23, 1, 0.32, 1] as const
export function PdpStickyAtc({
visible = true,
title = "Daily Greens",
image = "/brands/morrow/greens.webp",
variant = "30 servings",
price = 4900,
compareAt,
position = "fixed",
onAdd,
className,
}: {
visible?: boolean
title?: string
image?: string
variant?: string
price?: number
compareAt?: number
position?: "fixed" | "absolute"
onAdd?: () => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("pdp", "pdp-sticky-atc")
const [status, setStatus] = useState<"idle" | "loading" | "added">("idle")
const timers = useRef<number[]>([])
useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])
const add = () => {
if (status !== "idle") return
setStatus("loading")
sk.emit("convert", "add_to_cart", price)
timers.current.push(
window.setTimeout(() => {
setStatus("added")
onAdd?.()
timers.current.push(window.setTimeout(() => setStatus("idle"), 1200))
}, 700)
)
}
return (
<motion.div
{...sk.attrs}
ref={sk.ref as React.RefObject<HTMLDivElement>}
role="region"
aria-label="Quick add to cart"
aria-hidden={!visible || undefined}
inert={!visible}
initial={false}
animate={{ y: visible || reduce ? 0 : "110%", opacity: visible ? 1 : 0 }}
transition={{ duration: reduce ? 0.15 : 0.45, ease: EASE }}
className={cn(position === "fixed" ? "fixed" : "absolute", "inset-x-0 bottom-0 z-30 p-3", !visible && "pointer-events-none", className)}
>
<div className="mx-auto flex max-w-3xl items-center gap-3 rounded-sk-card border border-sk-line bg-sk-card p-2.5 pr-3 shadow-[0_24px_60px_-24px_rgb(var(--sk-ink)/0.25)]">
<span className="relative h-12 w-12 shrink-0 overflow-hidden rounded-sk-btn bg-sk-surface">
<img src={image} alt="" className="absolute inset-0 h-full w-full object-cover" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-sk-ink">{title}</span>
<span className="flex flex-wrap items-baseline gap-x-2 text-[13px] text-sk-muted">
<span className="font-sk-mono text-[11px] uppercase tracking-[0.08em]">{variant}</span>
<Price amount={price} compareAt={compareAt} size="sm" />
</span>
</span>
<Button onClick={add} loading={status === "loading"} className="min-w-[96px] shrink-0">
{status === "added" ? (
<>
<Check size={16} strokeWidth={2.5} /> Added
</>
) : (
"Add"
)}
</Button>
</div>
</motion.div>
)
}
Usage
Drive `visible` from an IntersectionObserver on your main add to cart button.
