Reservation timer

Countdown to a real stock-hold expiry. Renders nothing without expiresAt and says plainly when the hold has ended.

componenttimeranyreservation
$npx shadcn@latest add @storekit/cart-timer
cart-timer

Without an expiresAt prop the timer renders nothing.

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

Source

components/storekit/cart/cart-timer.tsx
76 lines
"use client"

import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { Clock } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Reservation timer, only for real holds. It renders nothing unless you
   pass the actual expiry your inventory system set, it counts down to that
   moment (computed after mount), and when the hold ends it says so plainly
   instead of restarting. */

export function CartTimer({
  expiresAt,
  holdMinutes,
  className,
}: {
  /** When the stock hold ends. Date, ISO string or epoch ms. Omit and nothing renders. */
  expiresAt?: Date | string | number
  /** Length of the hold, for the progress bar and copy. */
  holdMinutes?: number
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-timer")
  const [now, setNow] = React.useState<number | null>(null)
  const end = expiresAt === undefined ? null : new Date(expiresAt).getTime()

  React.useEffect(() => {
    if (end === null || Number.isNaN(end)) return
    setNow(Date.now())
    const id = window.setInterval(() => setNow(Date.now()), 1000)
    return () => window.clearInterval(id)
  }, [end])

  if (end === null || Number.isNaN(end)) return null

  const left = now === null ? null : Math.max(0, end - now)
  const expired = left === 0
  const mm = left === null ? "--" : String(Math.floor(left / 60000))
  const ss = left === null ? "--" : String(Math.floor((left % 60000) / 1000)).padStart(2, "0")
  const total = holdMinutes ? holdMinutes * 60000 : null
  const frac = total && left !== null ? Math.min(1, left / total) : 1

  return (
    <div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("w-full overflow-hidden rounded-sk-card border border-sk-line bg-sk-card", className)}>
      <div className="flex items-start gap-3 p-4">
        <Clock size={16} className={cn("mt-0.5 shrink-0", expired ? "text-sk-muted" : "text-sk-ink")} />
        <div className="min-w-0 flex-1" role="timer" aria-live="off">
          {expired ? (
            <>
              <p className="text-sm font-medium text-sk-ink">Your hold has ended</p>
              <p className="mt-0.5 text-[13px] leading-[1.5] text-sk-muted">Items are still in your cart. Stock is no longer set aside, so availability is checked again at checkout.</p>
            </>
          ) : (
            <>
              <p className="text-sm font-medium text-sk-ink">
                Held for you for <span className="tabular-nums">{mm}:{ss}</span>
              </p>
              <p className="mt-0.5 text-[13px] leading-[1.5] text-sk-muted">
                {holdMinutes ? `We set stock aside for ${holdMinutes} minutes when you add a limited-run item.` : "We set this stock aside when you added it."} After that it returns to general stock.
              </p>
            </>
          )}
        </div>
      </div>
      {total && !expired ? (
        <div className="h-1 bg-sk-ink/[0.06]">
          <motion.div className="h-full origin-left bg-sk-accent" initial={false} animate={{ scaleX: frac }} transition={{ duration: reduce ? 0 : 1, ease: "linear" }} />
        </div>
      ) : null}
    </div>
  )
}

Usage

Pass the expiry your inventory system set. Never reset it client side.