Empty cart

Token-drawn bag illustration, a human line of copy, a bestsellers action and a two-item rail.

componentemptyanycartatcrecommendations
$npx shadcn@latest add @storekit/cart-empty
cart-empty

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

Source

components/storekit/cart/cart-empty.tsx
119 lines
"use client"

import * as React from "react"
import { useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { Check, Plus } 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 { ProductImage } from "@/components/storekit/ui/product-image"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Empty cart: a quiet token-drawn illustration, a human line of copy, one
   clear action and a two-item rail so the next step is one tap away. */

const EASE: [number, number, number, number] = [0.16, 1, 0.3, 1]

type Suggestion = { id: string; title: string; price: number; compareAt?: number; image: string }

const SAMPLE: Suggestion[] = [
  { id: "daily-greens", title: "Daily Greens", price: 4900, image: "/brands/morrow/greens.webp" },
  { id: "electrolytes", title: "Electrolytes", price: 3200, image: "/brands/morrow/electrolytes.webp" },
]

function BagIllustration() {
  return (
    <svg viewBox="0 0 120 120" className="h-28 w-28" aria-hidden>
      <circle cx="60" cy="60" r="54" className="fill-sk-surface stroke-sk-accent/40" strokeWidth="1.5" strokeDasharray="4 6" />
      <path d="M38 46h44l-3 40a6 6 0 0 1-6 5.5H47a6 6 0 0 1-6-5.5L38 46z" className="fill-sk-card stroke-sk-ink/50" strokeWidth="2" strokeLinejoin="round" />
      <path d="M49 52v-9a11 11 0 0 1 22 0v9" className="fill-none stroke-sk-ink/50" strokeWidth="2" strokeLinecap="round" />
      <path d="M52 70h16" className="stroke-sk-line" strokeWidth="2" strokeLinecap="round" />
    </svg>
  )
}

export function CartEmpty({
  suggestions = SAMPLE,
  onShop,
  onAdd,
  className,
}: {
  suggestions?: Suggestion[]
  onShop?: () => void
  onAdd?: (id: string) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-empty")
  const [added, setAdded] = useState<string | null>(null)
  const add = (id: string) => {
    setAdded(id)
    sk.emit("interact", "suggestion_add")
    onAdd?.(id)
    window.setTimeout(() => setAdded((a) => (a === id ? null : a)), 1200)
  }
  const rise = (i: number) => ({
    initial: reduce ? false : { opacity: 0, y: 8 },
    animate: { opacity: 1, y: 0 },
    transition: { duration: 0.5, delay: i * 0.05, ease: EASE },
  })
  return (
    <div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("mx-auto flex w-full max-w-sm flex-col items-center text-center", className)}>
      <motion.div {...rise(0)}>
        <BagIllustration />
      </motion.div>
      <motion.h3 {...rise(1)} className="mt-5 font-sk-display text-xl font-semibold leading-[1.1] tracking-[-0.02em]">
        Your cart is empty.
      </motion.h3>
      <motion.p {...rise(2)} className="mt-2 text-sm text-sk-muted">
        The best sellers are a good place to start.
      </motion.p>
      <motion.div {...rise(3)} className="mt-5">
        <Button
          onClick={() => {
            sk.emit("convert", "shop_cta")
            onShop?.()
          }}
        >
          Shop bestsellers
        </Button>
      </motion.div>

      {suggestions.length ? (
        <motion.div {...rise(4)} className="mt-10 w-full text-left">
          <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Most reordered</p>
          <ul className="mt-3 grid grid-cols-2 gap-3">
            {suggestions.slice(0, 2).map((s) => {
              const done = added === s.id
              return (
                <li key={s.id} className="flex flex-col overflow-hidden rounded-sk-card border border-sk-line bg-sk-card">
                  <ProductImage src={s.image} alt={s.title} fit="cover" />
                  <div className="flex flex-1 flex-col gap-2 p-3">
                    <p className="line-clamp-2 hyphens-auto break-words text-sm font-medium leading-snug">{s.title}</p>
                    <div className="mt-auto flex items-end justify-between gap-2">
                      <Price amount={s.price} compareAt={s.compareAt} size="sm" className="min-w-0" />
                      <button
                        type="button"
                        aria-label={done ? `${s.title} added` : `Add ${s.title}`}
                        onClick={() => add(s.id)}
                        className={cn(
                          "grid h-11 w-11 shrink-0 place-items-center rounded-sk-btn border transition-[transform,background-color,color] duration-200 active:scale-[0.97]",
                          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper",
                          done ? "border-sk-savings/40 text-sk-savings" : "border-sk-line text-sk-ink hover:bg-sk-ink/[0.05]"
                        )}
                      >
                        {done ? <Check size={16} /> : <Plus size={16} />}
                      </button>
                    </div>
                  </div>
                </li>
              )
            })}
          </ul>
        </motion.div>
      ) : null}
    </div>
  )
}

Usage

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

Empty cart, StoreKit