E-commerceecommerceshopannouncementbannerpromo
Promo Bar
Slim announcement bar with rotating messages, pause on hover and a dismiss button remembered per session.
Live Preview
Free shipping on orders over $75Use code ASTRO10 for 10% off your first order30-day free returns
Usage
Copy and paste this code into your React component:
tsx
import { PromoBar } from 'react-component-kit-ui/ecommerce';
<PromoBar messages={['Free shipping over $75', 'Code ASTRO10 = 10% off']} />Manual Installation
If you are not using the npm package, create a new file and paste the following code: src/components/PromoBar.tsx + PromoBar.css:
PromoBar.tsx
'use client';
import { useEffect, useRef } from 'react';
import './PromoBar.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(' ');
}
/**
* PromoBar โ Slim announcement bar for shipping offers, sales or codes,
* with rotating messages and an optional dismiss button.
*
* @param {string[]} messages - One or more messages; several rotate every `interval` ms.
* @param {string} href - Optional link for the whole bar.
* @param {number} interval - Rotation interval in ms. Default 4500.
* @param {boolean} dismissible - Show a close button (remembered per session). Default true.
* @param {string} variant - Style (gold|glass|dark). Default 'gold'.
* @param {string} size - Height (sm|md). Default 'sm'.
* @param {string} storageKey - sessionStorage key for the dismissed state. Default 'ack-promo-closed'.
* @param {string} className - Extra class.
*
* Accessibility: rotation pauses on hover/focus and is disabled with reduced motion;
* messages live in a polite live region only after the user interacts, never on load.
*/
export interface PromoBarProps {
messages?: string[];
href?: string;
interval?: number;
dismissible?: boolean;
variant?: 'gold' | 'glass' | 'dark';
size?: 'sm' | 'md';
storageKey?: string;
className?: string;
}
export function PromoBar({
messages = ['Free shipping on orders over $75', 'Use code ASTRO10 for 10% off your first order', '30-day free returns'],
href,
interval = 4500,
dismissible = true,
variant = 'gold',
size = 'sm',
storageKey = 'ack-promo-closed',
className = '',
}: PromoBarProps) {
const Tag: any = href ? 'a' : 'div';
// 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-promo-bar]').forEach((bar) => {
const key = bar.dataset.key || 'ack-promo-closed';
try {
if (window.sessionStorage.getItem(key)) { bar.hidden = true; return; }
} catch { /* storage blocked */ }
bar.querySelector('[data-promo-close]')?.addEventListener('click', () => {
bar.hidden = true;
try { window.sessionStorage.setItem(key, '1'); } catch { /* ignore */ }
});
const msgs = Array.from(bar.querySelectorAll<HTMLElement>('.promo-bar__msg'));
if (msgs.length < 2 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let index = 0;
let paused = false;
bar.addEventListener('mouseenter', () => { paused = true; });
bar.addEventListener('mouseleave', () => { paused = false; });
bar.addEventListener('focusin', () => { paused = true; });
bar.addEventListener('focusout', () => { paused = false; });
window.setInterval(() => {
if (paused || document.hidden) return;
msgs[index].classList.remove('is-active');
msgs[index].setAttribute('aria-hidden', 'true');
index = (index + 1) % msgs.length;
msgs[index].classList.add('is-active');
msgs[index].removeAttribute('aria-hidden');
}, Number(bar.dataset.interval) || 4500);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className={cx(['promo-bar', `promo-bar--${variant}`, `promo-bar--${size}`, className])} data-promo-bar data-interval={interval} data-key={storageKey} data-rk-ca2577="">
<Tag className="promo-bar__track" href={href} data-rk-ca2577="">
{messages.map((m, i) => (
<span key={i} className={cx(['promo-bar__msg', { 'is-active': i === 0 }])} aria-hidden={i === 0 ? undefined : 'true'} data-rk-ca2577="">{m}</span>
))}
</Tag>
{dismissible && (
<button className="promo-bar__close" type="button" aria-label="Dismiss announcement" data-promo-close data-rk-ca2577="">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true" data-rk-ca2577=""><path d="M18 6 6 18M6 6l12 12" data-rk-ca2577="" /></svg>
</button>
)}
</div>
</>
);
}
export default PromoBar;PromoBar.css
/* PromoBar โ ported from astro-component-kit (ecommerce/PromoBar.astro) */
.promo-bar:where([data-rk-ca2577]) {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 48px;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.promo-bar[hidden]:where([data-rk-ca2577]) { display: none; }
.promo-bar--md:where([data-rk-ca2577]) { min-height: 44px; font-size: 0.875rem; }
.promo-bar--gold:where([data-rk-ca2577]) { background: linear-gradient(90deg, #b8860b, #d4a017, #b8860b); color: #140f00; }
.promo-bar--glass:where([data-rk-ca2577]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.04); border-bottom: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05)); color: var(--c-text-1, #fff); }
.promo-bar--dark:where([data-rk-ca2577]) { background: var(--c-bg-2, #111); color: var(--c-text-1, #fff); }
.promo-bar__track:where([data-rk-ca2577]) {
display: grid;
place-items: center;
width: 100%;
color: inherit;
text-decoration: none;
}
a.promo-bar__track:hover:where([data-rk-ca2577]) { text-decoration: underline; color: inherit; }
.promo-bar__msg:where([data-rk-ca2577]) {
grid-area: 1 / 1;
text-align: center;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.4s ease, transform 0.4s ease;
pointer-events: none;
}
.promo-bar__msg.is-active:where([data-rk-ca2577]) { opacity: 1; transform: none; pointer-events: auto; }
.promo-bar__close:where([data-rk-ca2577]) {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 36px;
height: 36px;
border: 0;
border-radius: var(--r-full, 9999px);
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.75;
}
.promo-bar__close:hover:where([data-rk-ca2577]) { opacity: 1; background: rgba(0, 0, 0, 0.1); }
.promo-bar__close:focus-visible:where([data-rk-ca2577]) { outline: 2px solid currentColor; outline-offset: -2px; }
@media (prefers-reduced-motion: reduce) {
.promo-bar__msg:where([data-rk-ca2577]) { transition: none; }
}Quick Info
- Category
- E-commerce
- Filename
PromoBar.tsx- Dependencies
- None โ pure React + CSS
- Tags
- ecommerceshopannouncementbannerpromo