Browse the library
Components / Feedback
Promo bar
FreeAnnouncement bar on a warm drifting ember surface with a short phone text, a live countdown and a dismissal that never flashes back on reload.
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
/* ---------- Ember surface engine (same as ember-gradient, inlined so this file stays self-contained) ---------- */
export type EmberPreset = "ember" | "moss" | "dusk";
export interface EmberPalette {
/** Solid base under the fields. Keep it dark so white text stays readable. */
base: string;
/** Up to five field colors, placed top left, bottom left, center, bottom right, top right. */
fields: string[];
}
/** A preset name, a palette object, or an array whose first entry is the base. */
export type EmberPaletteInput = EmberPreset | EmberPalette | string[];
export const emberPalettes: Record<EmberPreset, EmberPalette> = {
ember: { base: "#17130F", fields: ["#895735", "#193D25", "#444A29", "#943C27", "#895735"] },
moss: { base: "#10140F", fields: ["#5A6B32", "#1F4A2C", "#3E4A2A", "#7A5A2E", "#26402F"] },
dusk: { base: "#16121A", fields: ["#7A3F2F", "#2C2F4A", "#4A2E44", "#9A5A33", "#3A2A3E"] },
};
function resolvePalette(input: EmberPaletteInput): EmberPalette {
if (typeof input === "string") return emberPalettes[input] ?? emberPalettes.ember;
if (Array.isArray(input)) return { base: input[0] ?? emberPalettes.ember.base, fields: input.slice(1) };
return input;
}
/* Field placement. Sizes use container units: wide surfaces get wide bands
(like a 52px bar), tall ones get round pools instead of vertical stripes.
`w` and `h` are percent of the surface, `cap` limits height to cap x width,
`push` moves a field past its anchored edge by that share of its own size. */
type Field = {
x: "left" | "right" | "center";
y: "top" | "bottom" | "center";
w: number;
h: number;
cap: number;
push: [number, number];
opacity: number;
drift: "a" | "b" | "c";
factor: number;
delay: number;
};
const FIELDS: Field[] = [
{ x: "left", y: "top", w: 70, h: 100, cap: 1.1, push: [0.25, 0.35], opacity: 0.85, drift: "a", factor: 1, delay: 0 },
{ x: "left", y: "bottom", w: 62, h: 95, cap: 1.1, push: [0.25, 0.3], opacity: 0.9, drift: "b", factor: 1.27, delay: -0.3 },
{ x: "center", y: "center", w: 52, h: 84, cap: 1, push: [0, 0], opacity: 0.4, drift: "c", factor: 0.86, delay: -0.55 },
{ x: "right", y: "bottom", w: 72, h: 100, cap: 1.1, push: [0.22, 0.3], opacity: 0.9, drift: "b", factor: 1.13, delay: -0.7 },
{ x: "right", y: "top", w: 50, h: 84, cap: 1, push: [0.2, 0.4], opacity: 0.5, drift: "a", factor: 0.94, delay: -0.2 },
];
function fieldBox(f: Field): React.CSSProperties {
const width = `max(${f.w}cqw, ${f.w * 0.55}cqh)`;
const height = `min(${f.h}cqh, max(${f.w * f.cap}cqw, ${f.w * 0.55 * f.cap}cqh))`;
const box: React.CSSProperties = { width, height };
if (f.x === "center") box.left = `calc(50% - ${width} / 2)`;
else box[f.x] = `calc(${width} * ${-f.push[0]})`;
if (f.y === "center") box.top = `calc(50% - ${height} / 2)`;
else box[f.y] = `calc(${height} * ${-f.push[1]})`;
return box;
}
/* Transform and opacity only, so the drift runs on the compositor. The fields
are soft radial gradients, no blur filter is repainted per frame. */
const EMBER_CSS = `
@keyframes facade-ember-a{0%{transform:translate3d(0,0,0) scale(1);opacity:1}50%{transform:translate3d(10%,14%,0) scale(1.08);opacity:.78}100%{transform:translate3d(-6%,6%,0) scale(.95);opacity:1}}
@keyframes facade-ember-b{0%{transform:translate3d(0,0,0) scale(1.04)}50%{transform:translate3d(-12%,-6%,0) scale(.94)}100%{transform:translate3d(6%,-12%,0) scale(1.1)}}
@keyframes facade-ember-c{0%{transform:translate3d(0,0,0) scale(.95);opacity:.6}50%{transform:translate3d(14%,-10%,0) scale(1.12);opacity:1}100%{transform:translate3d(-10%,8%,0) scale(1);opacity:.7}}
.facade-ember-field{position:absolute;border-radius:9999px;will-change:transform,opacity;animation-timing-function:ease-in-out;animation-iteration-count:infinite;animation-direction:alternate}
.facade-ember[data-paused] .facade-ember-field{animation-play-state:paused}
.facade-ember-stack{position:absolute;inset:0;transition-property:opacity;transition-timing-function:ease-in-out}
@media (prefers-reduced-motion:reduce){.facade-ember-field{animation:none!important}.facade-ember-stack{transition:none!important}}
`;
const GRAIN =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")";
interface EmberLayerProps {
palette: EmberPaletteInput;
cycle?: boolean;
cyclePalettes?: EmberPaletteInput[];
cycleDuration?: number;
speed?: number;
grain?: boolean | number;
intensity?: number;
/** Pauses drift and cycling, e.g. while the surface is off screen. */
paused?: boolean;
}
function EmberLayer({
palette,
cycle = false,
cyclePalettes,
cycleDuration = 30,
speed = 26,
grain = true,
intensity = 1,
paused = false,
}: EmberLayerProps) {
const reduce = useReducedMotion();
const first = resolvePalette(palette);
const others = (cyclePalettes ?? (Object.keys(emberPalettes) as EmberPreset[]).filter((k) => k !== palette)).map(
resolvePalette,
);
const stacks = cycle ? [first, ...others] : [first];
const [active, setActive] = React.useState(0);
const running = cycle && !reduce && !paused && stacks.length > 1;
React.useEffect(() => {
if (!running) return;
const id = window.setInterval(() => setActive((i) => (i + 1) % stacks.length), cycleDuration * 1000);
return () => window.clearInterval(id);
}, [running, stacks.length, cycleDuration]);
const fade = Math.min(8, cycleDuration / 3);
const grainOpacity = grain === true ? 0.16 : typeof grain === "number" ? grain : 0;
return (
<div
aria-hidden
className="facade-ember pointer-events-none absolute inset-0 -z-10 overflow-hidden"
data-paused={paused || undefined}
style={{ backgroundColor: `var(--ember-base, ${first.base})`, containerType: "size" }}
>
<style href="facade-ember-v1" precedence="medium">
{EMBER_CSS}
</style>
{stacks.map((p, s) => (
<div
key={s}
className="facade-ember-stack"
style={{
opacity: s === active ? 1 : 0,
transitionDuration: `${fade}s`,
backgroundColor: s === 0 ? undefined : p.base,
}}
>
<div className="absolute inset-0" style={{ opacity: intensity }}>
{FIELDS.map((f, i) => {
const raw = p.fields[i % Math.max(1, p.fields.length)];
if (!raw) return null;
// The first palette reads CSS variables, so a theme can override colors without props.
const color = s === 0 ? `var(--ember-${i + 1}, ${raw})` : raw;
const duration = speed * f.factor;
return (
<span
key={i}
className="facade-ember-field"
style={{
...fieldBox(f),
opacity: f.opacity,
// A short plateau before the falloff keeps small surfaces from showing hot spots.
background: `radial-gradient(closest-side, ${color} 0%, ${color} 22%, color-mix(in srgb, ${color} 50%, transparent) 62%, transparent 100%)`,
animationName: `facade-ember-${f.drift}`,
animationDuration: `${duration}s`,
animationDelay: `${f.delay * duration}s`,
}}
/>
);
})}
</div>
</div>
))}
{grainOpacity > 0 && (
<div
className="absolute inset-0 mix-blend-overlay"
style={{ opacity: grainOpacity, backgroundImage: GRAIN, backgroundSize: "160px 160px" }}
/>
)}
</div>
);
}
/** Pauses the surface while it is scrolled out of view. */
function useOffscreen<T extends Element>(ref: React.RefObject<T | null>) {
const [off, setOff] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([e]) => setOff(!e.isIntersecting), { rootMargin: "120px" });
io.observe(el);
return () => io.disconnect();
}, [ref]);
return off;
}
/* ---------- end of engine ---------- */
export interface PromoBarProps {
/** Message on tablet and desktop. */
text: React.ReactNode;
/** Shorter message below 640px. Falls back to `text`. */
mobileText?: React.ReactNode;
/** Short bold lead, e.g. "Fall special". Hidden on phones to save space. */
badge?: string;
href?: string;
linkLabel?: string;
/** ISO date with offset ("2026-11-30T23:59:00-05:00"). Shows a live countdown next to the text. */
countdownTo?: string;
/** Words before the countdown. Defaults to "Ends in". */
countdownLabel?: string;
/** Show the countdown below 640px too. Off by default so the bar stays one line on phones. */
countdownOnMobile?: boolean;
/** Hide the bar once the countdown reaches zero. Defaults to true. */
hideWhenExpired?: boolean;
dismissible?: boolean;
/** localStorage key that remembers the dismissal. Change it for every new campaign. */
storageKey?: string;
/** Called after the bar has collapsed. */
onDismiss?: () => void;
/** Surface colors. Defaults to "ember". */
palette?: EmberPaletteInput;
/** Cross-fade slowly between the presets. */
cycle?: boolean;
/** Let the color fields drift. Defaults to true. Always static with reduced motion. */
drift?: boolean;
className?: string;
}
const EASE = [0.16, 1, 0.3, 1] as const;
const ATTR = "data-promo-bar-dismissed";
const safeKey = (key: string) => key.replace(/[^a-zA-Z0-9_-]/g, "");
/**
* Pre-paint script that hides an already dismissed bar before first paint.
* The bar renders this inline right above itself, so it works without setup.
* If you render the bar lower in the tree, you can also put it in <head>:
* <script dangerouslySetInnerHTML={{ __html: promoBarScript("fall-tuneup-2026") }} />
* This module is a client module, so call it from a client component or copy the string.
*/
export function promoBarScript(storageKey = "facade-promo-bar") {
const key = safeKey(storageKey);
return `try{if(localStorage.getItem(${JSON.stringify(key)})==="1"){var d=document.documentElement,v=d.getAttribute("${ATTR}")||"";if((" "+v+" ").indexOf(" ${key} ")<0)d.setAttribute("${ATTR}",(v+" ${key}").trim())}}catch(e){}`;
}
function markDismissed(key: string) {
const d = document.documentElement;
const v = d.getAttribute(ATTR) ?? "";
if (!` ${v} `.includes(` ${key} `)) d.setAttribute(ATTR, `${v} ${key}`.trim());
}
const useIsomorphicLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
function remaining(target: number) {
const total = Math.max(0, Math.floor((target - Date.now()) / 1000));
return {
total,
d: Math.floor(total / 86400),
h: Math.floor((total % 86400) / 3600),
m: Math.floor((total % 3600) / 60),
s: total % 60,
};
}
const pad = (n: number) => String(n).padStart(2, "0");
export function PromoBar({
text,
mobileText,
badge,
href,
linkLabel = "Learn more",
countdownTo,
countdownLabel = "Ends in",
countdownOnMobile = false,
hideWhenExpired = true,
dismissible = true,
storageKey = "facade-promo-bar",
onDismiss,
palette = "ember",
cycle = false,
drift = true,
className,
}: PromoBarProps) {
const reduce = useReducedMotion();
const key = safeKey(storageKey);
const target = countdownTo ? Date.parse(countdownTo) : NaN;
const hasCountdown = Number.isFinite(target);
// The countdown renders after mount only, server and client clocks never agree to the second.
const [left, setLeft] = React.useState<ReturnType<typeof remaining> | null>(null);
const [open, setOpen] = React.useState(true);
const ref = React.useRef<HTMLDivElement>(null);
const offscreen = useOffscreen(ref);
React.useEffect(() => {
if (!hasCountdown) return;
const tick = () => setLeft(remaining(target));
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [hasCountdown, target]);
// Client-rendered bars (client navigation, dynamic imports) never run the inline script.
// A layout effect runs before paint, so a dismissed bar is hidden before it can flash.
useIsomorphicLayoutEffect(() => {
try {
if (localStorage.getItem(key) === "1") markDismissed(key);
} catch {}
}, [key]);
function dismiss() {
setOpen(false);
try {
localStorage.setItem(key, "1");
} catch {}
}
function collapsed() {
markDismissed(key);
onDismiss?.();
}
if (hasCountdown && hideWhenExpired && left?.total === 0) return null;
return (
<>
<script suppressHydrationWarning dangerouslySetInnerHTML={{ __html: promoBarScript(key) }} />
<style href={`facade-promo-bar-${key}`} precedence="medium">
{`html[${ATTR}~="${key}"] [data-promo-bar="${key}"]{display:none}`}
</style>
<motion.div
ref={ref}
role="region"
aria-label="Promotion"
data-promo-bar={key}
initial={false}
animate={open ? { height: "auto" } : { height: 0 }}
transition={{ duration: reduce ? 0 : 0.4, ease: EASE }}
onAnimationComplete={() => {
if (!open) collapsed();
}}
className={cn("relative isolate w-full overflow-hidden text-white", className)}
>
<EmberLayer palette={palette} cycle={cycle} paused={!drift || offscreen} grain />
<div className="mx-auto flex min-h-12 max-w-[1120px] items-center gap-3 px-4 py-2 sm:min-h-14 sm:px-6">
{dismissible && <span className="w-7 shrink-0" aria-hidden />}
<p className="flex min-w-0 flex-1 flex-wrap items-center justify-center gap-x-3 gap-y-0.5 text-center text-[13px] leading-snug sm:text-sm">
<span className="min-w-0">
{badge && <span className="mr-1.5 hidden font-semibold sm:inline">{badge}</span>}
<span className="text-white/90 sm:hidden">{mobileText ?? text}</span>
<span className="hidden text-white/90 sm:inline">{text}</span>
</span>
{hasCountdown && (
<span
role="timer"
aria-label={
left ? `${countdownLabel} ${left.d} days, ${left.h} hours and ${left.m} minutes` : countdownLabel
}
className={cn(
"shrink-0 items-center gap-1.5 rounded-full bg-black/25 px-2.5 py-0.5 text-[12px] font-medium tabular-nums text-white ring-1 ring-white/15",
countdownOnMobile ? "inline-flex" : "hidden sm:inline-flex",
)}
>
<span className="text-white/70" aria-hidden>
{countdownLabel}
</span>
{/* Fixed width placeholder until mount, so nothing shifts when the time appears */}
<span aria-hidden className={cn(!left && "invisible")}>
{left ? (left.d > 0 ? `${left.d}d ${pad(left.h)}h ${pad(left.m)}m` : `${pad(left.h)}:${pad(left.m)}:${pad(left.s)}`) : "0d 00h 00m"}
</span>
</span>
)}
{href && (
<a
href={href}
className="shrink-0 rounded font-medium underline decoration-white/50 underline-offset-4 transition-colors hover:decoration-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
>
{linkLabel}
</a>
)}
</p>
{dismissible && (
<button
type="button"
onClick={dismiss}
aria-label="Dismiss promotion"
className="grid size-7 shrink-0 place-items-center rounded-full text-white/75 transition hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
>
<X className="size-4" aria-hidden />
</button>
)}
</div>
</motion.div>
</>
);
}
export default PromoBar;"use client";
import * as React from "react";
import { Flame, Menu } from "lucide-react";
import { PromoBar } from "@/components/facade/promo-bar";
const KEY = "facade-demo-promo-bar";
/** End of the current month at 11:59 pm, so the demo countdown is always running. */
function endOfMonth() {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 0);
}
export default function Demo() {
const [run, setRun] = React.useState(0);
const [dismissed, setDismissed] = React.useState(false);
const end = React.useMemo(endOfMonth, []);
const day = end.toLocaleDateString("en-US", { month: "long", day: "numeric" });
function showAgain() {
try {
localStorage.removeItem(KEY);
} catch {}
const d = document.documentElement;
const v = (d.getAttribute("data-promo-bar-dismissed") ?? "").split(" ").filter((k) => k && k !== KEY);
d.setAttribute("data-promo-bar-dismissed", v.join(" "));
setDismissed(false);
setRun((r) => r + 1);
}
return (
<div className="mx-auto w-full max-w-4xl p-4">
<div className="overflow-hidden rounded-[22px] bg-background shadow-float">
<PromoBar
key={run}
storageKey={KEY}
badge="Tune-up special"
text={`$89 furnace or AC tune-up, through ${day}.`}
mobileText="$89 furnace or AC tune-up."
href="#book"
linkLabel="Book a visit"
countdownTo={end.toISOString()}
onDismiss={() => setDismissed(true)}
/>
<header className="flex h-14 items-center justify-between border-b border-border px-5">
<div className="flex items-center gap-2.5 text-[15px] font-semibold tracking-[-0.01em]">
<span className="grid size-7 place-items-center rounded-lg bg-foreground text-background">
<Flame className="size-3.5" aria-hidden />
</span>
Blue Ridge Comfort Co.
</div>
<nav className="hidden items-center gap-7 text-[14px] text-muted-foreground sm:flex">
<span>Heating</span>
<span>Cooling</span>
<span>Service plans</span>
<span className="rounded-full bg-foreground px-4 py-1.5 text-[13px] font-medium text-background">Book service</span>
</nav>
<Menu className="size-5 text-muted-foreground sm:hidden" aria-hidden />
</header>
<div className="px-5 py-10">
<div className="h-3 w-24 rounded-full bg-muted" />
<div className="mt-4 h-7 w-3/4 max-w-md rounded-lg bg-muted" />
<div className="mt-2 h-7 w-1/2 max-w-xs rounded-lg bg-muted" />
{dismissed && (
<button
type="button"
onClick={showAgain}
className="mt-6 rounded-full bg-muted px-3.5 py-1.5 text-[13px] font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Show the bar again
</button>
)}
</div>
</div>
</div>
);
}Installation
npx shadcn@latest add https://facadeui.com/r/promo-bar.jsonUsage
import { PromoBar } from "@/components/facade/promo-bar";
export default function Page() {
return <PromoBar />;
}Props
PromoBarProps is exported, so you can extend or wrap it. Every prop has a default; <PromoBar /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| text* | React.ReactNode | Message on tablet and desktop. |
| mobileText | React.ReactNode | Shorter message below 640px. Falls back to `text`. |
| badge | string | Short bold lead, e.g. "Fall special". Hidden on phones to save space. |
| href | string | |
| linkLabel | string | |
| countdownTo | string | ISO date with offset ("2026-11-30T23:59:00-05:00"). Shows a live countdown next to the text. |
| countdownLabel | string | Words before the countdown. Defaults to "Ends in". |
| countdownOnMobile | boolean | Show the countdown below 640px too. Off by default so the bar stays one line on phones. |
| hideWhenExpired | boolean | Hide the bar once the countdown reaches zero. Defaults to true. |
| dismissible | boolean | |
| storageKey | string | localStorage key that remembers the dismissal. Change it for every new campaign. |
| onDismiss | () => void | Called after the bar has collapsed. |
| palette | EmberPaletteInput | Surface colors. Defaults to "ember". |
| cycle | boolean | Cross-fade slowly between the presets. |
| drift | boolean | Let the color fields drift. Defaults to true. Always static with reduced motion. |
| className | string |
* required
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade promo bar to this page and adapt the copy to our business.
FAQ
How do I install the promo bar?
Run npx shadcn@latest add https://facadeui.com/r/promo-bar.json in a project with shadcn initialized. The source lands in components/facade/promo-bar.tsx and you own it.
Is the promo bar free?
Yes. It is MIT licensed and can be used in personal and commercial projects without a key.
Can I customize the promo bar?
Yes. It is plain React and Tailwind source in your project. Colors come from semantic tokens such as bg-brand, so your globals.css restyles it. 16 props are typed and exported as PromoBarProps.