Typed quantity with undo
Validated typed quantity input that explains bad values, and removal with a 5-second Undo that restores the line in place.
componentrowanycartquantity
$
npx shadcn@latest add @storekit/cart-qty-undocart-qty-undo
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-qty-undo$
pnpm dlx shadcn@latest add @storekit/cart-qty-undo$
yarn dlx shadcn@latest add @storekit/cart-qty-undo$
bunx --bun shadcn@latest add @storekit/cart-qty-undoSource
components/storekit/cart/cart-qty-undo.tsx
203 lines
"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Trash2, Undo2 } 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"
/* Typed quantity with validation, and removal you can take back. The input
accepts whole numbers within stock, commits on blur or Enter, and explains
a bad value instead of silently clamping it. Removing a line shows an Undo
bar for 5 seconds that puts the line back where it was. */
export type UndoLine = { id: string; title: string; variant?: string; price: number; image: string; quantity: number; stock?: number }
const SAMPLE: UndoLine[] = [
{ id: "daily-greens", title: "Daily Greens", variant: "30 servings", price: 4900, image: "/brands/morrow/greens.webp", quantity: 2, stock: 12 },
{ id: "electrolytes", title: "Electrolytes", variant: "30 sticks", price: 3200, image: "/brands/morrow/electrolytes.webp", quantity: 1, stock: 4 },
{ id: "tablets", title: "Magnesium Night", variant: "60 gummies", price: 2900, image: "/brands/morrow/magnesium.webp", quantity: 1, stock: 20 },
]
function QtyInput({ line, onCommit }: { line: UndoLine; onCommit: (q: number) => void }) {
const max = line.stock ?? 99
const [draft, setDraft] = React.useState(String(line.quantity))
const [error, setError] = React.useState<string>()
const id = React.useId()
React.useEffect(() => setDraft(String(line.quantity)), [line.quantity])
const commit = () => {
const v = draft.trim()
if (!/^\d+$/.test(v)) return setError("Enter a whole number.")
const n = Number(v)
if (n < 1) return setError("Use Remove to take it out.")
if (n > max) return setError(`Only ${max} in stock.`)
setError(undefined)
if (n !== line.quantity) onCommit(n)
}
return (
<div>
<label htmlFor={id} className="sr-only">
Quantity for {line.title}
</label>
<input
id={id}
inputMode="numeric"
autoComplete="off"
value={draft}
aria-invalid={!!error}
aria-describedby={error ? `${id}-err` : undefined}
onChange={(e) => {
setDraft(e.target.value)
if (error) setError(undefined)
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
commit()
}
if (e.key === "Escape") {
setDraft(String(line.quantity))
setError(undefined)
}
}}
className={cn(
"h-10 w-16 rounded-sk-btn border bg-sk-card px-2 text-center text-sm tabular-nums text-sk-ink transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2",
error ? "border-sk-sale focus-visible:ring-sk-sale/30" : "border-sk-line focus-visible:border-sk-accent focus-visible:ring-sk-accent/30"
)}
/>
{error ? (
<p id={`${id}-err`} role="alert" className="mt-1 text-xs text-sk-sale">
{error}
</p>
) : null}
</div>
)
}
export function CartQtyUndo({
items = SAMPLE,
undoMs = 5000,
onChange,
contained = true,
className,
}: {
items?: UndoLine[]
undoMs?: number
onChange?: (lines: UndoLine[]) => void
/** Keep the Undo bar inside this block. Set false to pin it to the viewport. */
contained?: boolean
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("cart", "cart-qty-undo")
const [lines, setLines] = React.useState(items)
const [removed, setRemoved] = React.useState<{ line: UndoLine; index: number } | null>(null)
const subtotal = lines.reduce((n, l) => n + l.price * l.quantity, 0)
React.useEffect(() => {
if (!removed) return
const t = window.setTimeout(() => setRemoved(null), undoMs)
return () => window.clearTimeout(t)
}, [removed, undoMs])
const commit = (next: UndoLine[]) => {
setLines(next)
onChange?.(next)
}
const remove = (l: UndoLine) => {
const index = lines.findIndex((x) => x.id === l.id)
commit(lines.filter((x) => x.id !== l.id))
setRemoved({ line: l, index })
sk.emit("interact", "remove", l.price * l.quantity)
}
const undo = () => {
if (!removed) return
const next = [...lines]
next.splice(Math.min(removed.index, next.length), 0, removed.line)
commit(next)
setRemoved(null)
sk.emit("interact", "undo_remove", removed.line.price * removed.line.quantity)
}
return (
<div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("relative w-full pb-16", className)}>
<ul className="border-y border-sk-line">
<AnimatePresence initial={false}>
{lines.map((l) => (
<motion.li
key={l.id}
layout={!reduce}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden border-b border-sk-line last:border-b-0"
>
<div className="flex gap-3 py-4">
<ProductImage src={l.image} alt={l.title} className="h-16 w-16 shrink-0 rounded-sk-card border border-sk-line" fit="cover" zoomOnHover={false} />
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{l.title}</p>
<p className="text-[13px] text-sk-muted">
{l.variant ? `${l.variant} / ` : null}
<span className="tabular-nums">{money(l.price)}</span> each
</p>
</div>
<p className="shrink-0 text-sm font-semibold tabular-nums text-sk-price">{money(l.price * l.quantity)}</p>
</div>
<div className="mt-2 flex items-start justify-between gap-3">
<QtyInput line={l} onCommit={(q) => commit(lines.map((x) => (x.id === l.id ? { ...x, quantity: q } : x)))} />
<button
type="button"
onClick={() => remove(l)}
className="-mr-2 inline-flex h-10 items-center gap-1.5 rounded-sk-btn px-2 text-[13px] text-sk-muted transition-colors duration-200 hover:text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.97]"
>
<Trash2 size={14} />
Remove
</button>
</div>
</div>
</div>
</motion.li>
))}
</AnimatePresence>
</ul>
<div className="flex justify-between pt-3 text-sm">
<span className="text-sk-muted">Subtotal</span>
<span className="font-semibold tabular-nums">{money(subtotal)}</span>
</div>
<div aria-live="polite" className={cn(contained ? "absolute" : "fixed", "inset-x-0 bottom-0 z-20 flex justify-center")}>
<AnimatePresence>
{removed ? (
<motion.div
key={removed.line.id}
role="status"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] }}
className="flex w-full max-w-sm items-center gap-3 overflow-hidden rounded-sk-card bg-sk-ink py-1.5 pl-4 pr-1.5 text-sk-paper shadow-[0_24px_60px_-24px_rgb(0_0_0/0.4)]"
>
<p className="min-w-0 flex-1 truncate text-[13px]">Removed {removed.line.title}</p>
<button
type="button"
onClick={undo}
className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-sk-btn px-3 text-[13px] font-semibold text-sk-paper transition-colors duration-200 hover:bg-sk-paper/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-paper active:scale-[0.97]"
>
<Undo2 size={14} />
Undo
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
)
}
Usage
Import from "@/components/storekit/cart/cart-qty-undo" and pass your product data. The demo above exercises the full prop surface with sample values.


