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-countdown
hero-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-countdown

Source

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.

Countdown hero, StoreKit