Add to cart toast
Confirmation toast with thumbnail, item and a View cart action. Auto-dismisses after 4s and pauses on hover or focus.
componenttoastanyatccart
$
npx shadcn@latest add @storekit/atc-toastatc-toast
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/atc-toast$
pnpm dlx shadcn@latest add @storekit/atc-toast$
yarn dlx shadcn@latest add @storekit/atc-toast$
bunx --bun shadcn@latest add @storekit/atc-toastSource
components/storekit/cart/atc-toast.tsx
138 lines
"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, X } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Add-to-cart confirmation toast. Thumbnail, item, price and a View cart
action. Dismisses itself after `duration` ms; hovering or focusing it
pauses the clock and leaving resumes with the time that was left.
Announced politely, never steals focus. */
export type AtcToastItem = { title: string; variant?: string; price: number; image: string; quantity?: number }
const SAMPLE_ITEM: AtcToastItem = { title: "Electrolytes", variant: "30 sticks", price: 3200, image: "/brands/morrow/electrolytes.webp", quantity: 1 }
export function AtcToast({
open,
onOpenChange,
item = SAMPLE_ITEM,
duration = 4000,
contained = false,
onViewCart,
className,
}: {
open: boolean
onOpenChange: (open: boolean) => void
item?: AtcToastItem
/** Auto-dismiss delay in ms. */
duration?: number
/** Pin to the nearest relative parent instead of the viewport. */
contained?: boolean
onViewCart?: () => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("cart", "atc-toast")
const [paused, setPaused] = React.useState(false)
const remaining = React.useRef(duration)
const startedAt = React.useRef(0)
// Reset the clock each time the toast opens.
React.useEffect(() => {
if (open) remaining.current = duration
}, [open, duration])
React.useEffect(() => {
if (!open || paused) return
startedAt.current = Date.now()
const t = window.setTimeout(() => onOpenChange(false), remaining.current)
return () => window.clearTimeout(t)
}, [open, paused, onOpenChange])
const qty = item.quantity ?? 1
const pause = () => {
if (paused) return
remaining.current = Math.max(0, remaining.current - (Date.now() - startedAt.current))
setPaused(true)
}
const resume = () => setPaused(false)
return (
<div
aria-live="polite"
className={cn(contained ? "absolute" : "fixed", "inset-x-4 top-[max(1rem,env(safe-area-inset-top))] z-50 flex justify-center sm:inset-x-auto sm:right-4 sm:justify-end", className)}
>
<AnimatePresence>
{open ? (
<motion.div
key="sk-atc-toast"
ref={sk.ref as React.Ref<HTMLDivElement>}
{...sk.attrs}
role="status"
onMouseEnter={pause}
onMouseLeave={resume}
onFocus={pause}
onBlur={resume}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: reduce ? 0 : 0.4, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-sm overflow-hidden rounded-sk-card border border-sk-line bg-sk-card shadow-[0_24px_60px_-24px_rgb(0_0_0/0.4)]"
>
<div className="flex items-start gap-3 p-3">
<ProductImage src={item.image} alt="" className="h-14 w-14 shrink-0 rounded-sk-card" fit="cover" zoomOnHover={false} />
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1.5 text-[13px] font-medium text-sk-savings">
<Check size={14} />
Added to cart
</p>
<p className="mt-0.5 truncate text-sm font-medium text-sk-ink">{item.title}</p>
<p className="text-[13px] tabular-nums text-sk-muted">
{item.variant ? `${item.variant} / ` : null}
{qty > 1 ? `${qty} x ` : null}
{money(item.price)}
</p>
</div>
<button
type="button"
aria-label="Dismiss"
onClick={() => onOpenChange(false)}
className="-mr-1 -mt-1 grid h-11 w-11 shrink-0 place-items-center rounded-sk-btn text-sk-muted transition-colors duration-200 hover:bg-sk-ink/[0.05] hover:text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.97]"
>
<X size={16} />
</button>
</div>
<div className="flex items-center justify-between gap-3 border-t border-sk-line px-3 py-2">
<span className="min-w-0 text-xs text-sk-muted">{paused ? "Paused while you read" : "Closes on its own"}</span>
<button
type="button"
onClick={() => {
sk.emit("interact", "view_cart")
onViewCart?.()
onOpenChange(false)
}}
className="sk-btn inline-flex h-10 shrink-0 items-center whitespace-nowrap rounded-sk-btn bg-sk-accent px-4 text-[13px] font-medium text-sk-accent-ink transition-[filter,transform] duration-200 hover:brightness-[1.06] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-card active:scale-[0.97]"
>
View cart
</button>
</div>
{/* Time left, scaled on X so only transform animates. Freezes while paused. */}
<motion.div
aria-hidden
key={paused ? "paused" : "running"}
className="h-0.5 origin-left bg-sk-accent"
initial={{ scaleX: remaining.current / duration }}
animate={{ scaleX: paused ? remaining.current / duration : 0 }}
transition={{ duration: paused || reduce ? 0 : remaining.current / 1000, ease: "linear" }}
/>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
Usage
Controlled by open/onOpenChange. Reopen it on each add; the clock resets every time it opens.


