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.
This offer has ended — new deals every Friday.
00Days
00Hours
00Minutes
00Seconds
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