1 estrella en GitHub = 1 componente nuevo.

Dar una estrella
E-commerceecommerceshopcountdowntimersalebannerurgency

Countdown Banner

Cuenta atrás en vivo para ofertas con días, horas, minutos y segundos, CTA y estado finalizado; fecha fija o plazo por visitante.

Vista Previa en Vivo

Flash sale

Up to 30% off the Storm collection

Waterproof shells, insulated layers and accessories. While stock lasts.

Shop the sale

Uso

Copia y pega este código directamente en tu componente React:

tsx
import { CountdownBanner } from 'react-component-kit-ui/ecommerce';

<CountdownBanner endsAt="2026-12-01T00:00:00Z" title="Black Friday ends soon" cta={{ label: 'Shop deals', href: '/shop/' }} />

Instalación Manual

Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/CountdownBanner.tsx + CountdownBanner.css:

CountdownBanner.tsx
'use client';

import { useEffect, useRef } from 'react';
import './CountdownBanner.css';

/** class:list → className */
function cx(...args: unknown[]): string {
  const out: string[] = [];
  const walk = (v: unknown) => {
    if (!v) return;
    if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
    else if (Array.isArray(v)) v.forEach(walk);
    else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
  };
  args.forEach(walk);
  return out.join(' ');
}

/**
 * CountdownBanner — Live sale countdown (days · hours · minutes · seconds) with title,
 * copy and CTA; switches to an "ended" message when the time runs out.
 *
 * @param {string} endsAt - ISO date the offer ends. When omitted, `hours` is used.
 * @param {number} hours - Rolling offer length in hours, counted from the visitor's first view (stored in localStorage). Default 48.
 * @param {string} eyebrow - Small label. Default 'Flash sale'.
 * @param {string} title - Headline. Default 'Up to 30% off the Storm collection'.
 * @param {string} text - Supporting copy.
 * @param {{label: string, href: string}} cta - Call to action.
 * @param {string} endedText - Message shown after the deadline. Default 'This offer has ended — new deals every Friday.'
 * @param {string} storageKey - localStorage key for the rolling deadline. Default 'ack-countdown'.
 * @param {string} variant - Style (glow|bar|card). Default 'glow'.
 * @param {string} size - Density (sm|md|lg). Default 'md'.
 * @param {string} className - Extra class.
 *
 * Accessibility: the ticking digits are aria-hidden; a visually hidden sentence with the remaining
 * time updates once a minute in a polite live region (no per-second announcements).
 */
export interface CountdownBannerProps {
  endsAt?: string;
  hours?: number;
  eyebrow?: string;
  title?: string;
  text?: string;
  cta?: { label: string; href: string };
  endedText?: string;
  storageKey?: string;
  variant?: 'glow' | 'bar' | 'card';
  size?: 'sm' | 'md' | 'lg';
  className?: string;
}

export function CountdownBanner({
  endsAt,
  hours = 48,
  eyebrow = 'Flash sale',
  title = 'Up to 30% off the Storm collection',
  text = 'Waterproof shells, insulated layers and accessories. While stock lasts.',
  cta = { label: 'Shop the sale', href: '#' },
  endedText = 'This offer has ended — new deals every Friday.',
  storageKey = 'ack-countdown',
  variant = 'glow',
  size = 'md',
  className = '',
}: CountdownBannerProps) {
  const units = [
    { key: 'd', label: 'Days' },
    { key: 'h', label: 'Hours' },
    { key: 'm', label: 'Minutes' },
    { key: 's', label: 'Seconds' },
  ];

  // Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
  const mounted = useRef(false);
  useEffect(() => {
    if (mounted.current) return;
    mounted.current = true;
    document.querySelectorAll<HTMLElement>('[data-countdown]:not([data-bound])').forEach((el) => {
        el.dataset.bound = '';
        let end = el.dataset.ends ? Date.parse(el.dataset.ends) : NaN;
        if (Number.isNaN(end)) {
          const key = el.dataset.key || 'ack-countdown';
          const ms = (Number(el.dataset.hours) || 48) * 3600_000;
          try {
            const saved = Number(window.localStorage.getItem(key));
            end = saved && saved > Date.now() ? saved : Date.now() + ms;
            window.localStorage.setItem(key, String(end));
          } catch {
            end = Date.now() + ms;
          }
        }
        const nums = Object.fromEntries(Array.from(el.querySelectorAll<HTMLElement>('[data-unit]')).map((n) => [n.dataset.unit, n]));
        const sr = el.querySelector<HTMLElement>('[data-sr]');
        const ended = el.querySelector<HTMLElement>('[data-ended]');
        const clock = el.querySelector<HTMLElement>('[data-clock]');
        let lastMinute = -1;
        let timer = 0;
    
        const pad = (n: number) => String(n).padStart(2, '0');
        function tick() {
          const left = Math.max(0, end - Date.now());
          const s = Math.floor(left / 1000);
          const parts = { d: Math.floor(s / 86400), h: Math.floor((s % 86400) / 3600), m: Math.floor((s % 3600) / 60), s: s % 60 };
          (Object.keys(parts) as Array<keyof typeof parts>).forEach((k) => {
            const node = nums[k];
            const value = pad(parts[k]);
            if (node && node.textContent !== value) {
              node.textContent = value;
              node.classList.remove('is-tick');
              void node.offsetWidth;
              node.classList.add('is-tick');
            }
          });
          const minute = Math.floor(s / 60);
          if (sr && minute !== lastMinute) {
            lastMinute = minute;
            sr.textContent = `Offer ends in ${parts.d} days, ${parts.h} hours and ${parts.m} minutes.`;
          }
          if (left === 0) {
            window.clearInterval(timer);
            el.classList.add('is-ended');
            if (ended) ended.hidden = false;
            if (clock) clock.hidden = true;
            if (sr) sr.textContent = ended?.textContent || 'Offer ended.';
          }
        }
        tick();
        timer = window.setInterval(tick, 1000);
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <section className={cx(['countdown', `countdown--${variant}`, `countdown--${size}`, className])} data-countdown data-ends={endsAt} data-hours={hours} data-key={storageKey} aria-label={title} data-rk-a26cb5="">
        <div className="countdown__inner" data-rk-a26cb5="">
        <div className="countdown__copy" data-rk-a26cb5="">
          {eyebrow && <p className="countdown__eyebrow" data-rk-a26cb5=""><span aria-hidden="true" data-rk-a26cb5=""></span>{eyebrow}</p>}
          <h2 className="countdown__title" data-rk-a26cb5="">{title}</h2>
          {text && <p className="countdown__text" data-live-text data-rk-a26cb5="">{text}</p>}
          <p className="countdown__ended" data-ended hidden data-rk-a26cb5="">{endedText}</p>
        </div>
        <div className="countdown__side" data-rk-a26cb5="">
          <div className="countdown__clock" aria-hidden="true" data-clock data-rk-a26cb5="">
            {units.map((u, __i: number) => (
              <div key={__i} className="countdown__unit" data-rk-a26cb5="">
                <span className="countdown__num" data-unit={u.key} data-rk-a26cb5="">00</span>
                <span className="countdown__label" data-rk-a26cb5="">{u.label}</span>
              </div>
            ))}
          </div>
          <p className="sr-only" aria-live="polite" data-sr data-rk-a26cb5=""></p>
          {cta && <a className="countdown__cta" href={cta.href} data-rk-a26cb5="">{cta.label}</a>}
        </div>
        </div>
      </section>
    </>
  );
}

export default CountdownBanner;
CountdownBanner.css
/* CountdownBanner — ported from astro-component-kit (ecommerce/CountdownBanner.astro) */

.countdown:where([data-rk-a26cb5]) {
  container-type: inline-size;
  position: relative;
  padding: clamp(1.5rem, 4vw, 2.75rem);
  border-radius: var(--r-xl, 28px);
  border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.08);
  overflow: hidden;
  isolation: isolate;
}
.countdown--glow:where([data-rk-a26cb5]) {
  background:
    radial-gradient(circle at 100% 0%, rgba(248, 113, 113, 0.16), transparent 45%),
    radial-gradient(circle at 0% 100%, rgba(212, 160, 23, 0.16), transparent 45%),
    var(--c-surface, rgb(var(--ink-rgb, 255 255 255) / 0.02));
}
.countdown__inner:where([data-rk-a26cb5]) { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--sp-8, 2rem); }
.countdown--card:where([data-rk-a26cb5]) { background: var(--c-bg-1, #0a0a0a); }
.countdown--bar:where([data-rk-a26cb5]) { border-radius: var(--r-md, 12px); padding: var(--sp-3, 0.75rem) var(--sp-5, 1.25rem); }
.countdown--bar:where([data-rk-a26cb5]) .countdown__inner:where([data-rk-a26cb5]) { grid-template-columns: auto 1fr auto; gap: var(--sp-5, 1.25rem); }
.countdown--bar:where([data-rk-a26cb5]) .countdown__copy:where([data-rk-a26cb5]) { display: flex; align-items: center; gap: var(--sp-4, 1rem); }
.countdown--bar:where([data-rk-a26cb5]) .countdown__title:where([data-rk-a26cb5]) { font-size: 1rem; }
.countdown--bar:where([data-rk-a26cb5]) .countdown__text:where([data-rk-a26cb5]), .countdown--bar:where([data-rk-a26cb5]) .countdown__eyebrow:where([data-rk-a26cb5]) { display: none; }
.countdown--bar:where([data-rk-a26cb5]) .countdown__side:where([data-rk-a26cb5]) { display: contents; }
.countdown--sm:where([data-rk-a26cb5]) { padding: var(--sp-5, 1.25rem); }

.countdown__eyebrow:where([data-rk-a26cb5]) {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2, 0.5rem);
  margin: 0 0 var(--sp-3, 0.75rem);
  font-size: 0.75rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--c-danger, #f87171);
}
.countdown__eyebrow:where([data-rk-a26cb5]) span:where([data-rk-a26cb5]) { width: 8px; height: 8px; border-radius: 50%; background: currentColor; animation: countdown-banner-cd-pulse 1.6s ease-in-out infinite; }
.countdown__title:where([data-rk-a26cb5]) { margin: 0; font-size: clamp(1.4rem, 3vw, 2.1rem); font-weight: 900; letter-spacing: -0.03em; line-height: 1.15; color: var(--c-text-1, #fff); }
.countdown__text:where([data-rk-a26cb5]), .countdown__ended:where([data-rk-a26cb5]) { margin: var(--sp-3, 0.75rem) 0 0; max-width: 34rem; color: var(--c-text-2, #a3a3a3); }
.countdown__ended[hidden]:where([data-rk-a26cb5]) { display: none; }

.countdown__side:where([data-rk-a26cb5]) { display: grid; justify-items: end; gap: var(--sp-4, 1rem); }
.countdown__clock:where([data-rk-a26cb5]) { display: flex; gap: var(--sp-2, 0.5rem); }
.countdown__clock[hidden]:where([data-rk-a26cb5]) { display: none; }
.countdown__unit:where([data-rk-a26cb5]) {
  display: grid;
  justify-items: center;
  min-width: 72px;
  padding: var(--sp-3, 0.75rem) var(--sp-2, 0.5rem);
  border-radius: var(--r-md, 12px);
  border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.1);
  background: rgb(var(--paper-rgb, 5 5 5) / 0.5);
}
.countdown--bar:where([data-rk-a26cb5]) .countdown__unit:where([data-rk-a26cb5]) { min-width: 52px; padding: 4px 6px; }
.countdown__num:where([data-rk-a26cb5]) { font-size: 1.9rem; font-weight: 900; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; color: var(--c-text-1, #fff); line-height: 1.1; }
.countdown--bar:where([data-rk-a26cb5]) .countdown__num:where([data-rk-a26cb5]) { font-size: 1.1rem; }
.countdown__num.is-tick:where([data-rk-a26cb5]) { animation: countdown-banner-cd-tick 0.35s ease-out; }
.countdown__label:where([data-rk-a26cb5]) { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
.countdown__cta:where([data-rk-a26cb5]) {
  display: inline-flex;
  align-items: center;
  min-height: 48px;
  padding: 0 var(--sp-6, 1.5rem);
  border-radius: var(--r-full, 9999px);
  background: var(--c-primary-light, #d4a017);
  color: var(--c-on-primary, #050505);
  font-weight: 800;
  text-decoration: none;
}
.countdown__cta:hover:where([data-rk-a26cb5]) { filter: brightness(1.08); color: var(--c-on-primary, #050505); }
.countdown__cta:focus-visible:where([data-rk-a26cb5]) { outline: 2px solid var(--c-text-1, #fff); outline-offset: 3px; }
.countdown.is-ended:where([data-rk-a26cb5]) .countdown__cta:where([data-rk-a26cb5]) { display: none; }

@keyframes countdown-banner-cd-tick { from { transform: translateY(-4px); opacity: 0.5; } }
@keyframes countdown-banner-cd-pulse { 50% { opacity: 0.35; } }
.sr-only:where([data-rk-a26cb5]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

@container (max-width: 760px) {
  .countdown__inner:where([data-rk-a26cb5]), .countdown--bar:where([data-rk-a26cb5]) .countdown__inner:where([data-rk-a26cb5]) { grid-template-columns: 1fr; }
  .countdown__side:where([data-rk-a26cb5]) { justify-items: stretch; }
  .countdown__clock:where([data-rk-a26cb5]) { justify-content: space-between; }
  .countdown__unit:where([data-rk-a26cb5]) { flex: 1; min-width: 0; }
  .countdown__cta:where([data-rk-a26cb5]) { justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
  .countdown__num.is-tick:where([data-rk-a26cb5]), .countdown__eyebrow:where([data-rk-a26cb5]) span:where([data-rk-a26cb5]) { animation: none; }
}

Información Rápida

Categoría
E-commerce
Nombre de archivo
CountdownBanner.tsx
Dependencias
Ninguna — React + CSS puro
Etiquetas
ecommerceshopcountdowntimersalebannerurgency