Web InnoventixFreeCode

Empty Cart

Original · free

empty shopping cart state

byWeb InnoventixReact + Tailwind
emptycartstates
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/empty-cart.json
empty-cart.tsx
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  type Transition,
} from "motion/react";

type ProductId = "grinder" | "tumbler" | "guji" | "dripper";

type Product = {
  id: ProductId;
  name: string;
  detail: string;
  price: number;
  tone: string;
};

type Line = {
  id: ProductId;
  qty: number;
};

const FREE_SHIPPING_AT = 7500;

const PRODUCTS: readonly Product[] = [
  {
    id: "grinder",
    name: "Ceramic Burr Hand Grinder",
    detail: "38 clicks, espresso to French press",
    price: 6800,
    tone: "from-amber-200 to-amber-400 dark:from-amber-500/30 dark:to-amber-400/10",
  },
  {
    id: "guji",
    name: "Ethiopia Guji — Whole Bean",
    detail: "340 g · peach, jasmine, cane sugar",
    price: 1950,
    tone: "from-rose-200 to-rose-400 dark:from-rose-500/30 dark:to-rose-400/10",
  },
  {
    id: "dripper",
    name: "Stainless Pour-Over Dripper",
    detail: "Paperless, fits a 500 ml carafe",
    price: 4200,
    tone: "from-sky-200 to-sky-400 dark:from-sky-500/30 dark:to-sky-400/10",
  },
  {
    id: "tumbler",
    name: "Double-Wall Glass Tumbler",
    detail: "350 ml · keeps a flat white hot for 40 min",
    price: 2400,
    tone: "from-emerald-200 to-emerald-400 dark:from-emerald-500/30 dark:to-emerald-400/10",
  },
];

const LAST_CART: readonly Line[] = [
  { id: "guji", qty: 2 },
  { id: "dripper", qty: 1 },
];

const money = (cents: number): string => `$${(cents / 100).toFixed(2)}`;

const findProduct = (id: ProductId): Product => {
  const hit = PRODUCTS.find((p) => p.id === id);
  if (!hit) throw new Error(`Unknown product: ${id}`);
  return hit;
};

function Glyph({ id }: { id: ProductId }) {
  const common = {
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.6,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
  };
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-6 w-6">
      {id === "grinder" && (
        <g {...common}>
          <path d="M8 4h8l-1 5H9L8 4Z" />
          <path d="M9 9h6l-.7 8.5a2 2 0 0 1-2 1.8h-.6a2 2 0 0 1-2-1.8L9 9Z" />
          <path d="M12 2.4V4" />
          <path d="M10.5 12.2h3" />
        </g>
      )}
      {id === "guji" && (
        <g {...common}>
          <path d="M15.6 4.6c2.6 2.6 2.6 6.8 0 9.4-2.6 2.6-6.8 2.6-9.4 0" />
          <path d="M6.2 14c-2.6-2.6-2.6-6.8 0-9.4 2.6-2.6 6.8-2.6 9.4 0" />
          <path d="M6.2 14c1.4-3.6 5-6.2 9.4-9.4" />
          <path d="M4.4 19.6c3.2-1.4 6.6-1.6 9.8-.6" />
        </g>
      )}
      {id === "dripper" && (
        <g {...common}>
          <path d="M4.5 6h15l-5.4 7.6v4.2a1 1 0 0 1-1 1h-2.2a1 1 0 0 1-1-1v-4.2L4.5 6Z" />
          <path d="M7.2 9.6h9.6" />
          <path d="M12 2.6V5" />
        </g>
      )}
      {id === "tumbler" && (
        <g {...common}>
          <path d="M7 4h10l-1.2 15.2a1.8 1.8 0 0 1-1.8 1.6h-4a1.8 1.8 0 0 1-1.8-1.6L7 4Z" />
          <path d="M7.4 9h9.2" />
          <path d="M14.4 12.4v4.4" />
        </g>
      )}
    </svg>
  );
}

function CartArt({ animated }: { animated: boolean }) {
  return (
    <div className="relative mx-auto h-44 w-52 sm:h-52 sm:w-60">
      <div
        aria-hidden="true"
        className="absolute inset-x-6 bottom-3 h-4 rounded-[100%] bg-slate-900/10 blur-md dark:bg-black/50"
      />
      <svg
        viewBox="0 0 220 180"
        aria-hidden="true"
        className="relative h-full w-full text-slate-400 dark:text-zinc-600"
      >
        <circle
          cx="110"
          cy="86"
          r="62"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeDasharray="5 9"
          strokeLinecap="round"
          className={animated ? "ec-dash origin-center" : "origin-center"}
          opacity="0.5"
        />
        <g
          className={animated ? "ec-bob" : undefined}
          style={{ transformOrigin: "110px 90px" }}
        >
          <path
            d="M46 50h13.5a5 5 0 0 1 4.9 4l3 15"
            fill="none"
            stroke="currentColor"
            strokeWidth="5"
            strokeLinecap="round"
          />
          <path
            d="M67.4 69h96.8a4 4 0 0 1 3.9 4.9l-7.4 32a8 8 0 0 1-7.8 6.2H84.6a8 8 0 0 1-7.9-6.5L67.4 69Z"
            className="fill-white stroke-current dark:fill-zinc-900"
            strokeWidth="5"
            strokeLinejoin="round"
          />
          <path
            d="M78 82h78"
            fill="none"
            stroke="currentColor"
            strokeWidth="3"
            strokeLinecap="round"
            opacity="0.45"
          />
          <path
            d="M99 69v43M127 69v43M155 69v40"
            fill="none"
            stroke="currentColor"
            strokeWidth="2.5"
            strokeLinecap="round"
            opacity="0.35"
          />
          <circle
            cx="96"
            cy="130"
            r="9"
            className="fill-white stroke-current dark:fill-zinc-900"
            strokeWidth="5"
          />
          <circle
            cx="148"
            cy="130"
            r="9"
            className="fill-white stroke-current dark:fill-zinc-900"
            strokeWidth="5"
          />
        </g>
        <g className="text-indigo-500 dark:text-indigo-400">
          <circle
            cx="86"
            cy="46"
            r="3.5"
            fill="currentColor"
            className={animated ? "ec-drift" : undefined}
            style={{ animationDelay: "0s" }}
            opacity={animated ? undefined : 0.6}
          />
          <circle
            cx="122"
            cy="38"
            r="2.5"
            fill="currentColor"
            className={animated ? "ec-drift" : undefined}
            style={{ animationDelay: "1.1s" }}
            opacity={animated ? undefined : 0.6}
          />
          <circle
            cx="156"
            cy="48"
            r="3"
            fill="currentColor"
            className={animated ? "ec-drift" : undefined}
            style={{ animationDelay: "2.2s" }}
            opacity={animated ? undefined : 0.6}
          />
        </g>
      </svg>
    </div>
  );
}

export default function EmptyCart() {
  const reduce = useReducedMotion();
  const animated = !reduce;

  const [lines, setLines] = useState<Line[]>([]);
  const [restored, setRestored] = useState(false);
  const [status, setStatus] = useState("");

  const browseRef = useRef<HTMLButtonElement | null>(null);
  const wasFilled = useRef(false);

  const spring: Transition = reduce
    ? { duration: 0 }
    : { type: "spring", stiffness: 460, damping: 36, mass: 0.7 };

  const fade: Transition = reduce ? { duration: 0 } : { duration: 0.26, ease: "easeOut" };

  const count = useMemo(
    () => lines.reduce((sum, line) => sum + line.qty, 0),
    [lines],
  );

  const subtotal = useMemo(
    () => lines.reduce((sum, line) => sum + findProduct(line.id).price * line.qty, 0),
    [lines],
  );

  const toGo = Math.max(0, FREE_SHIPPING_AT - subtotal);
  const shipPct = Math.min(100, Math.round((subtotal / FREE_SHIPPING_AT) * 100));

  useEffect(() => {
    if (lines.length > 0) {
      wasFilled.current = true;
      return;
    }
    if (wasFilled.current) {
      wasFilled.current = false;
      browseRef.current?.focus();
    }
  }, [lines.length]);

  const add = useCallback((id: ProductId) => {
    const product = findProduct(id);
    setLines((prev) => {
      const existing = prev.find((line) => line.id === id);
      if (existing) {
        return prev.map((line) =>
          line.id === id ? { ...line, qty: line.qty + 1 } : line,
        );
      }
      return [...prev, { id, qty: 1 }];
    });
    setStatus(`${product.name} added to your cart.`);
  }, []);

  const setQty = useCallback((id: ProductId, next: number) => {
    const product = findProduct(id);
    if (next <= 0) {
      setLines((prev) => prev.filter((line) => line.id !== id));
      setStatus(`${product.name} removed from your cart.`);
      return;
    }
    setLines((prev) =>
      prev.map((line) => (line.id === id ? { ...line, qty: next } : line)),
    );
    setStatus(`${product.name} quantity ${next}.`);
  }, []);

  const restore = useCallback(() => {
    setLines(LAST_CART.map((line) => ({ ...line })));
    setRestored(true);
    setStatus("Tuesday's cart restored. 3 items are back in your cart.");
  }, []);

  const empty = useCallback(() => {
    setLines([]);
    setRestored(false);
    setStatus("Cart emptied.");
  }, []);

  const qtyOf = (id: ProductId): number =>
    lines.find((line) => line.id === id)?.qty ?? 0;

  const isEmpty = lines.length === 0;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-zinc-950">
      <style>{`
        @keyframes ec-bob {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-7px); }
        }
        @keyframes ec-drift {
          0% { transform: translateY(6px) scale(0.6); opacity: 0; }
          25% { opacity: 0.9; }
          100% { transform: translateY(-30px) scale(0.4); opacity: 0; }
        }
        @keyframes ec-dash {
          to { stroke-dashoffset: -196; }
        }
        @keyframes ec-sheen {
          0% { transform: translateX(-120%); }
          60%, 100% { transform: translateX(320%); }
        }
        .ec-bob { animation: ec-bob 4.6s ease-in-out infinite; }
        .ec-drift { animation: ec-drift 3.4s ease-out infinite; }
        .ec-dash { animation: ec-dash 24s linear infinite; }
        .ec-sheen { animation: ec-sheen 3.8s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .ec-bob, .ec-drift, .ec-dash, .ec-sheen {
            animation: none !important;
          }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl">
        <div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm shadow-slate-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40">
          <header className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4 sm:px-8 dark:border-zinc-800">
            <div className="flex items-center gap-3">
              <h2 className="text-base font-semibold tracking-tight text-slate-900 dark:text-zinc-50">
                Your cart
              </h2>
              <motion.span
                key={count}
                initial={reduce ? false : { scale: 0.7, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                transition={spring}
                className={[
                  "inline-flex h-6 min-w-6 items-center justify-center rounded-full px-2 text-xs font-semibold tabular-nums",
                  count > 0
                    ? "bg-indigo-600 text-white dark:bg-indigo-500"
                    : "bg-slate-100 text-slate-500 dark:bg-zinc-800 dark:text-zinc-400",
                ].join(" ")}
              >
                {count}
              </motion.span>
            </div>
            <p className="text-xs text-slate-500 dark:text-zinc-400">
              Free shipping over {money(FREE_SHIPPING_AT)} · 30-day returns
            </p>
          </header>

          <div className="grid gap-px bg-slate-200 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] dark:bg-zinc-800">
            <div className="bg-white p-5 sm:p-8 dark:bg-zinc-900">
              <AnimatePresence mode="wait" initial={false}>
                {isEmpty ? (
                  <motion.div
                    key="empty"
                    initial={reduce ? false : { opacity: 0, y: 8 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                    transition={fade}
                    className="flex flex-col items-center text-center"
                  >
                    <CartArt animated={animated} />

                    <h3 className="mt-6 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-zinc-50">
                      Nothing in here yet
                    </h3>
                    <p className="mt-3 max-w-sm text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
                      Your cart is empty — no items, no holds, nothing charged.
                      Pick a bag of beans or start with the grinder everyone
                      buys twice.
                    </p>

                    <div className="mt-7 flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:justify-center">
                      <button
                        ref={browseRef}
                        type="button"
                        onClick={() => add("guji")}
                        className="relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-xl bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-white dark:focus-visible:ring-offset-zinc-900"
                      >
                        <span
                          aria-hidden="true"
                          className={[
                            "pointer-events-none absolute inset-y-0 -left-4 w-10 -skew-x-12 bg-white/25 dark:bg-zinc-900/20",
                            animated ? "ec-sheen" : "hidden",
                          ].join(" ")}
                        />
                        <svg
                          viewBox="0 0 24 24"
                          aria-hidden="true"
                          className="h-4 w-4"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="2"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                        >
                          <path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.9a2 2 0 0 0 2-1.5L21 8H6" />
                          <circle cx="10" cy="20" r="1.2" />
                          <circle cx="18" cy="20" r="1.2" />
                        </svg>
                        Start with the Guji
                      </button>

                      <button
                        type="button"
                        onClick={restore}
                        disabled={restored}
                        className="inline-flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:border-zinc-600 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
                      >
                        <svg
                          viewBox="0 0 24 24"
                          aria-hidden="true"
                          className="h-4 w-4"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="2"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                        >
                          <path d="M3 12a9 9 0 1 0 2.6-6.4" />
                          <path d="M3 4v5h5" />
                        </svg>
                        Restore Tuesday&rsquo;s cart
                      </button>
                    </div>

                    <p className="mt-5 text-xs text-slate-500 dark:text-zinc-500">
                      Saved carts stay put for 30 days. We never charge until
                      you hit checkout.
                    </p>
                  </motion.div>
                ) : (
                  <motion.div
                    key="filled"
                    initial={reduce ? false : { opacity: 0, y: 8 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                    transition={fade}
                  >
                    <ul className="divide-y divide-slate-200 dark:divide-zinc-800">
                      <AnimatePresence initial={false}>
                        {lines.map((line) => {
                          const product = findProduct(line.id);
                          return (
                            <motion.li
                              key={line.id}
                              layout={!reduce}
                              initial={reduce ? false : { opacity: 0, height: 0 }}
                              animate={{ opacity: 1, height: "auto" }}
                              exit={
                                reduce
                                  ? { opacity: 0 }
                                  : { opacity: 0, height: 0, marginTop: 0 }
                              }
                              transition={spring}
                              className="overflow-hidden"
                            >
                              <div className="flex items-center gap-4 py-4">
                                <span
                                  aria-hidden="true"
                                  className={`grid h-14 w-14 shrink-0 place-items-center rounded-xl bg-gradient-to-br text-slate-700 ring-1 ring-inset ring-slate-900/5 dark:text-zinc-200 dark:ring-white/10 ${product.tone}`}
                                >
                                  <Glyph id={product.id} />
                                </span>

                                <div className="min-w-0 flex-1">
                                  <p className="truncate text-sm font-semibold text-slate-900 dark:text-zinc-100">
                                    {product.name}
                                  </p>
                                  <p className="mt-0.5 truncate text-xs text-slate-500 dark:text-zinc-400">
                                    {product.detail}
                                  </p>
                                </div>

                                <div className="flex items-center gap-1 rounded-lg border border-slate-200 p-0.5 dark:border-zinc-700">
                                  <button
                                    type="button"
                                    onClick={() => setQty(line.id, line.qty - 1)}
                                    aria-label={`Decrease quantity of ${product.name}`}
                                    className="grid h-7 w-7 place-items-center rounded-md text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-300 dark:hover:bg-zinc-800"
                                  >
                                    <svg
                                      viewBox="0 0 16 16"
                                      aria-hidden="true"
                                      className="h-3.5 w-3.5"
                                      fill="none"
                                      stroke="currentColor"
                                      strokeWidth="2"
                                      strokeLinecap="round"
                                    >
                                      <path d="M3.5 8h9" />
                                    </svg>
                                  </button>
                                  <span className="w-6 text-center text-sm font-semibold tabular-nums text-slate-900 dark:text-zinc-100">
                                    {line.qty}
                                  </span>
                                  <button
                                    type="button"
                                    onClick={() => setQty(line.id, line.qty + 1)}
                                    aria-label={`Increase quantity of ${product.name}`}
                                    className="grid h-7 w-7 place-items-center rounded-md text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-300 dark:hover:bg-zinc-800"
                                  >
                                    <svg
                                      viewBox="0 0 16 16"
                                      aria-hidden="true"
                                      className="h-3.5 w-3.5"
                                      fill="none"
                                      stroke="currentColor"
                                      strokeWidth="2"
                                      strokeLinecap="round"
                                    >
                                      <path d="M8 3.5v9M3.5 8h9" />
                                    </svg>
                                  </button>
                                </div>

                                <p className="w-16 shrink-0 text-right text-sm font-semibold tabular-nums text-slate-900 dark:text-zinc-100">
                                  {money(product.price * line.qty)}
                                </p>
                              </div>
                            </motion.li>
                          );
                        })}
                      </AnimatePresence>
                    </ul>

                    <div className="mt-6 rounded-xl bg-slate-50 p-4 dark:bg-zinc-800/50">
                      <div className="flex items-baseline justify-between">
                        <span className="text-sm text-slate-600 dark:text-zinc-400">
                          Subtotal
                        </span>
                        <span className="text-lg font-semibold tabular-nums text-slate-900 dark:text-zinc-50">
                          {money(subtotal)}
                        </span>
                      </div>

                      <div
                        role="progressbar"
                        aria-valuemin={0}
                        aria-valuemax={100}
                        aria-valuenow={shipPct}
                        aria-label="Progress toward free shipping"
                        className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-zinc-700"
                      >
                        <motion.div
                          className="h-full rounded-full bg-emerald-500 dark:bg-emerald-400"
                          initial={false}
                          animate={{ width: `${shipPct}%` }}
                          transition={spring}
                        />
                      </div>
                      <p className="mt-2 text-xs text-slate-600 dark:text-zinc-400">
                        {toGo === 0
                          ? "Shipping is on us — you cleared the $75 line."
                          : `${money(toGo)} away from free shipping.`}
                      </p>
                    </div>

                    <div className="mt-5 flex flex-wrap items-center gap-3">
                      <button
                        type="button"
                        className="inline-flex flex-1 items-center justify-center rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900"
                      >
                        Checkout · {money(subtotal)}
                      </button>
                      <button
                        type="button"
                        onClick={empty}
                        className="inline-flex items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-600 transition hover:text-rose-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 dark:text-zinc-400 dark:hover:text-rose-400"
                      >
                        <svg
                          viewBox="0 0 24 24"
                          aria-hidden="true"
                          className="h-4 w-4"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="1.8"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                        >
                          <path d="M4 7h16M9 7V5h6v2M6 7l1 12.2A1.8 1.8 0 0 0 8.8 21h6.4a1.8 1.8 0 0 0 1.8-1.8L18 7" />
                        </svg>
                        Empty cart
                      </button>
                    </div>
                  </motion.div>
                )}
              </AnimatePresence>
            </div>

            <aside className="bg-white p-5 sm:p-8 dark:bg-zinc-900">
              <h3 className="text-sm font-semibold text-slate-900 dark:text-zinc-100">
                Start with a staple
              </h3>
              <p className="mt-1 text-xs text-slate-500 dark:text-zinc-400">
                Four things our regulars reorder most.
              </p>

              <ul className="mt-4 space-y-2">
                {PRODUCTS.map((product) => {
                  const qty = qtyOf(product.id);
                  return (
                    <li key={product.id}>
                      <div className="flex items-center gap-3 rounded-xl border border-slate-200 p-2.5 transition hover:border-slate-300 dark:border-zinc-800 dark:hover:border-zinc-700">
                        <span
                          aria-hidden="true"
                          className={`grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-gradient-to-br text-slate-700 ring-1 ring-inset ring-slate-900/5 dark:text-zinc-200 dark:ring-white/10 ${product.tone}`}
                        >
                          <Glyph id={product.id} />
                        </span>
                        <div className="min-w-0 flex-1">
                          <p className="truncate text-xs font-semibold text-slate-900 dark:text-zinc-100">
                            {product.name}
                          </p>
                          <p className="text-xs tabular-nums text-slate-500 dark:text-zinc-400">
                            {money(product.price)}
                          </p>
                        </div>
                        <button
                          type="button"
                          onClick={() => add(product.id)}
                          aria-label={`Add ${product.name} to cart, ${money(product.price)}`}
                          className="shrink-0 rounded-lg border border-slate-300 px-2.5 py-1.5 text-xs font-semibold text-slate-700 transition hover:border-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:text-zinc-200 dark:hover:border-indigo-400 dark:hover:text-indigo-400 dark:focus-visible:ring-offset-zinc-900"
                        >
                          {qty > 0 ? (
                            <span className="tabular-nums">In cart · {qty}</span>
                          ) : (
                            "Add"
                          )}
                        </button>
                      </div>
                    </li>
                  );
                })}
              </ul>

              <div className="mt-5 rounded-xl border border-dashed border-slate-300 p-3 dark:border-zinc-700">
                <p className="text-xs leading-relaxed text-slate-600 dark:text-zinc-400">
                  <span className="font-semibold text-slate-900 dark:text-zinc-100">
                    Roasted to order.
                  </span>{" "}
                  Beans ship within 48 hours of the roast date, so what lands on
                  your counter is never more than four days old.
                </p>
              </div>
            </aside>
          </div>
        </div>
      </div>

      <p aria-live="polite" role="status" className="sr-only">
        {status}
      </p>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →