Dispatch cutoff countdown
Live countdown to a same-day dispatch cutoff with flipping digits.
componentcountdownanyshippingurgency
$
npx shadcn@latest add @storekit/announcement-countdownannouncement-countdown
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/announcement-countdown$
pnpm dlx shadcn@latest add @storekit/announcement-countdown$
yarn dlx shadcn@latest add @storekit/announcement-countdown$
bunx --bun shadcn@latest add @storekit/announcement-countdownSource
components/storekit/announcement-bar/announcement-countdown.tsx
100 lines
"use client"
import { useEffect, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Truck } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
/* Shipping cutoff bar. Counts down to `cutoffHour` (local time, 24h) on
business days and swaps to a next-day line after it. Each digit flips on
its own so only the changing character moves. Renders the static line on
the server and starts ticking after mount, so there is no hydration drift. */
function remaining(now: Date, cutoffHour: number, businessDaysOnly: boolean) {
const day = now.getDay()
if (businessDaysOnly && (day === 0 || day === 6)) return null
const cutoff = new Date(now)
cutoff.setHours(cutoffHour, 0, 0, 0)
const ms = cutoff.getTime() - now.getTime()
if (ms <= 0) return null
const s = Math.floor(ms / 1000)
return { h: Math.floor(s / 3600), m: Math.floor((s % 3600) / 60), s: s % 60 }
}
function Digits({ value, reduce }: { value: string; reduce: boolean | null }) {
return (
<span className="inline-flex">
{value.split("").map((ch, i) => (
<span key={i} className="relative inline-block h-[1.25em] w-[1ch] overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={ch}
initial={reduce ? { opacity: 0 } : { y: 8, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduce ? { opacity: 0 } : { y: -8, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="absolute inset-0 text-center"
>
{ch}
</motion.span>
</AnimatePresence>
</span>
))}
</span>
)
}
export function AnnouncementCountdown({
cutoffHour = 14,
label = "for same-day dispatch",
afterCutoff = "Ships next business day",
showSeconds = true,
businessDaysOnly = true,
className,
}: {
cutoffHour?: number
label?: string
afterCutoff?: string
showSeconds?: boolean
/** When true, Saturday and Sunday always show the after-cutoff line. */
businessDaysOnly?: boolean
className?: string
}) {
const reduce = useReducedMotion()
const [now, setNow] = useState<Date | null>(null)
useEffect(() => {
setNow(new Date())
const id = window.setInterval(() => setNow(new Date()), 1000)
return () => window.clearInterval(id)
}, [])
const left = now ? remaining(now, cutoffHour, businessDaysOnly) : null
const pad = (n: number) => String(n).padStart(2, "0")
return (
<div
role="status"
aria-live="off"
data-sk-section="announcement-bar"
data-sk-variant="announcement-countdown"
className={cn("flex min-h-10 items-center justify-center gap-2 bg-sk-bar px-4 py-2 text-[13px] leading-5 text-sk-bar-ink", className)}
>
<Truck size={14} aria-hidden className="shrink-0 opacity-70" />
{left ? (
<p className="flex min-w-0 flex-wrap items-center justify-center gap-x-1.5 text-center">
<span className="whitespace-nowrap">Order in</span>
<span className="inline-flex items-center gap-1 font-sk-mono font-semibold tabular-nums" aria-label={`${left.h} hours ${left.m} minutes`}>
<span className="inline-flex" aria-hidden><Digits value={String(left.h)} reduce={reduce} />h</span>
<span className="inline-flex" aria-hidden><Digits value={pad(left.m)} reduce={reduce} />m</span>
{showSeconds ? <span className="inline-flex" aria-hidden><Digits value={pad(left.s)} reduce={reduce} />s</span> : null}
</span>
<span className="text-balance">{label}</span>
</p>
) : (
<p className={cn("text-balance text-center transition-opacity duration-300", now ? "opacity-100" : "opacity-0")}>{afterCutoff}</p>
)}
</div>
)
}
Usage
cutoffHour is local time in 24h. Weekends and post-cutoff show the next-day line.