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-toast
atc-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-toast

Source

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.

Add to cart toast, StoreKit