Countdown hero
Live timer tied to a required real deadline, flip digits, minute-level polite announcements and an expired state.
blockcountdownanyctaurgency
$
npx shadcn@latest add @storekit/hero-countdownhero-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/hero-countdown$
pnpm dlx shadcn@latest add @storekit/hero-countdown$
yarn dlx shadcn@latest add @storekit/hero-countdown$
bunx --bun shadcn@latest add @storekit/hero-countdownSource
components/storekit/hero/hero-countdown.tsx
185 lines
"use client"
import { useEffect, useMemo, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Countdown hero tied to a real deadline. `deadline` is required and must be
the actual end of the sale or the real shipping cutoff; there is no
evergreen or resetting mode, because fake urgency breaks trust and FTC
guidance. The visual timer ticks every second and is hidden from screen
readers; a polite live region announces the remaining time at most once
a minute. When the deadline passes the timer is replaced by `expired`
copy (or the block renders nothing with `hideWhenExpired`). The timer
renders on the client only so server and client markup agree. */
const EASE = [0.23, 1, 0.32, 1] as const
type Cta = { label: string; href: string }
const PRIMARY =
"sk-btn inline-flex min-h-12 items-center justify-center py-2.5 text-center leading-tight rounded-sk-btn bg-sk-accent px-7 text-[15px] font-medium text-sk-accent-ink outline-none transition-[filter,transform] duration-200 ease-sk-out hover:brightness-[1.06] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97]"
const SECONDARY =
"sk-btn inline-flex min-h-12 items-center justify-center py-2.5 text-center leading-tight rounded-sk-btn border border-sk-line px-7 text-[15px] font-medium text-sk-ink outline-none transition-[background-color,transform] duration-200 ease-sk-out hover:bg-sk-ink/[0.04] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97]"
function split(ms: number) {
const s = Math.max(0, Math.floor(ms / 1000))
return { d: Math.floor(s / 86400), h: Math.floor((s % 86400) / 3600), m: Math.floor((s % 3600) / 60), s: s % 60 }
}
function spoken(ms: number) {
const { d, h, m } = split(ms)
const parts = [d && `${d} day${d === 1 ? "" : "s"}`, h && `${h} hour${h === 1 ? "" : "s"}`, `${m} minute${m === 1 ? "" : "s"}`].filter(Boolean)
return parts.join(", ")
}
function Digit({ value, reduce }: { value: string; reduce: boolean | null }) {
return (
<span className="relative inline-block overflow-hidden tabular-nums">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={value}
initial={{ y: reduce ? 0 : "-60%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: reduce ? 0 : "60%", opacity: 0 }}
transition={reduce ? { duration: 0.15 } : { type: "spring", stiffness: 420, damping: 34 }}
className="inline-block"
>
{value}
</motion.span>
</AnimatePresence>
</span>
)
}
export function HeroCountdown({
deadline,
eyebrow = "Final weekend",
label = "Sale ends in",
headline = "20% off every routine, ends Sunday.",
subcopy = "Applied automatically at checkout. Subscriptions lock in the sale price for your first three deliveries.",
expired = "This sale has ended. Sign up to hear about the next one first.",
hideWhenExpired = false,
primaryCta = { label: "Shop the sale", href: "#" },
secondaryCta = { label: "See what's included", href: "#" },
image = "/placeholder.webp",
imageAlt = "",
className,
}: {
/** The real end of the sale or the real shipping cutoff. ISO string, epoch ms or Date. */
deadline: string | number | Date
eyebrow?: string
/** Sits above the timer, e.g. "Sale ends in" or "Order within". */
label?: string
headline?: string
subcopy?: string
expired?: string
hideWhenExpired?: boolean
primaryCta?: Cta
secondaryCta?: Cta
image?: string
imageAlt?: string
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("hero", "hero-countdown")
const end = useMemo(() => new Date(deadline).getTime(), [deadline])
const valid = Number.isFinite(end)
const [now, setNow] = useState<number | null>(null)
useEffect(() => {
if (!valid) return
setNow(Date.now())
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [valid])
const left = now === null ? null : end - now
const done = !valid || (left !== null && left <= 0)
// Changes once a minute, so the live region speaks at most once a minute.
const minuteText = left !== null && left > 0 ? `${spoken(left)} left` : ""
const endsAt = useMemo(
() => (now !== null && valid ? new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(end) : ""),
// eslint-disable-next-line react-hooks/exhaustive-deps
[end, valid, now === null]
)
if (done && hideWhenExpired && now !== null) return null
const t = split(left ?? 0)
const cells: [string, string][] = [
[String(t.d).padStart(2, "0"), "Days"],
[String(t.h).padStart(2, "0"), "Hours"],
[String(t.m).padStart(2, "0"), "Min"],
[String(t.s).padStart(2, "0"), "Sec"],
]
return (
<section ref={sk.ref} {...sk.attrs} className={cn("bg-sk-paper text-sk-ink", className)}>
<div className="mx-auto grid w-full max-w-6xl items-center gap-10 px-4 py-10 sm:px-8 sm:py-14 lg:grid-cols-[1.1fr_0.9fr] lg:gap-14">
<motion.div initial={{ opacity: 0, y: reduce ? 0 : 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, ease: EASE }} className="order-2 lg:order-1">
<p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{eyebrow}</p>
<h1 className="mt-4 max-w-xl text-balance font-sk-display font-semibold leading-[1.08] tracking-[-0.02em]" style={{ fontSize: "clamp(2rem, 4.2vw, 3.25rem)" }}>
{headline}
</h1>
<div className="mt-8 min-h-[104px]">
{done && now !== null ? (
<p className="max-w-md rounded-sk-card border border-sk-line bg-sk-surface p-4 text-[15px] text-sk-ink">{expired}</p>
) : (
<>
<p className="flex flex-wrap items-baseline gap-x-2 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">
{label}
{endsAt ? (
<time dateTime={new Date(end).toISOString()} className="normal-case tracking-normal">
({endsAt})
</time>
) : null}
</p>
<div aria-hidden className="mt-3 grid max-w-md grid-cols-4 gap-2 sm:gap-3">
{cells.map(([v, l]) => (
<div key={l} className="flex flex-col items-center rounded-sk-card border border-sk-line bg-sk-card px-1 py-3 sm:py-4">
<span className="font-sk-mono text-[28px] font-medium leading-none text-sk-ink sm:text-[40px]">
{now === null ? "--" : (
<>
<Digit value={v[0]} reduce={reduce} />
<Digit value={v.slice(1)} reduce={reduce} />
</>
)}
</span>
<span className="mt-2 font-sk-mono text-[10px] uppercase tracking-[0.14em] text-sk-muted">{l}</span>
</div>
))}
</div>
</>
)}
<p className="sr-only" aria-live="polite" aria-atomic="true">
{minuteText}
</p>
</div>
<p className="mt-6 max-w-md text-[15px] leading-[1.55] text-sk-muted">{subcopy}</p>
<div className="mt-7 flex flex-col gap-3 sm:flex-row">
<a href={primaryCta.href} onClick={() => sk.emit("interact", "cta")} className={PRIMARY}>
{primaryCta.label}
</a>
<a href={secondaryCta.href} onClick={() => sk.emit("interact", "cta_secondary")} className={SECONDARY}>
{secondaryCta.label}
</a>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: reduce ? 1 : 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, ease: EASE, delay: reduce ? 0 : 0.1 }}
className="relative order-1 aspect-[4/3] w-full overflow-hidden rounded-sk-card border border-sk-line bg-sk-surface lg:order-2 lg:aspect-[4/5]"
>
<img src={image} alt={imageAlt} className="absolute inset-0 h-full w-full object-cover" />
</motion.div>
</div>
</section>
)
}
Usage
`deadline` is required and must be the real sale end or shipping cutoff. There is no evergreen mode. Use `hideWhenExpired` to remove the block after it passes.
