Lightweight, dependency‑free React banner component that automatically shows a holiday/special‑day strip at the top of the page based on the client’s local time. No server time, no SSR coupling. Configure entirely with TypeScript data (or JSON) and drop a single client component into any React/Next.js app.
✅ Always uses browser time after hydration.
✅ Works with React 18+ and Next.js (App/Pages Router), Vite, CRA.
✅ No CSS framework required; inline styles + optional class hooks.
✅ Zero external runtime dependencies.
✅ Single visible banner at a time (highest priority wins).
npm i react-holiday-banner
# or
yarn add react-holiday-banner
# or
pnpm add react-holiday-bannerPeer dependency: react >= 18
Create a holidays.ts file next to your component:
// holidays.ts
import type { Holiday } from "react-holiday-banner";
const holidays: Holiday[] = [
{
id: "new-year",
active: true,
range: {
start: { month: 12, day: 31 },
end: { month: 1, day: 1 },
inclusive: true,
},
display: { layout: "full", position: "sticky" },
content: {
text: "Happy New Year! 🎉",
image: {
src: "/confetti.png",
alt: "Confetti",
position: "right",
maxHeight: "32px",
},
},
style: {
background: "#eef6ff",
textColor: "#0b63ce",
height: "56px",
paddingX: "24px",
fontSize: "16px",
gap: "12px",
align: "center",
zIndex: 50,
},
priority: 10,
},
];
export default holidays;Use it in your app header/layout:
import { HolidayBanner } from "react-holiday-banner";
import holidaysData from "./holidays";
export default function Header() {
// Optional: freeze time for testing
const testTime = new Date("2026-01-01T10:00:00");
return (
<HolidayBanner
holidaysData={holidaysData}
holidaysDateOverride={
process.env.NODE_ENV === "development" ? testTime : undefined
}
/>
);
}The banner renders after hydration using client time (or
holidaysDateOverridewhen provided).
If you're using Next.js 13+ (App Router), remember that layout.tsx and page.tsx are Server Components by default.
react-holiday-banner is a Client Component, so you cannot use it directly in a server layout.
Instead, wrap it inside a small client wrapper component.
// app/_components/BannerClient.tsx
"use client";
import { HolidayBanner } from "react-holiday-banner";
import type { Holiday } from "react-holiday-banner";
export default function BannerClient({ holidays }: { holidays: Holiday[] }) {
return <HolidayBanner holidaysData={holidays} />;
}// app/holidays.ts (or anywhere you prefer)
import type { Holiday } from "react-holiday-banner";
const holidays: Holiday[] = [
{
id: "newyear",
active: true,
range: {
start: { month: 12, day: 31 },
end: { month: 1, day: 1 },
inclusive: true,
},
content: { text: "🎉 Happy New Year!" },
style: { background: "#eef6ff", textColor: "#0b63ce" },
},
];
export default holidays;// app/layout.tsx
import type { ReactNode } from "react";
import BannerClient from "@/app/_components/BannerClient";
import holidays from "@/app/holidays";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<BannerClient holidays={holidays} />
{children}
</body>
</html>
);
}✅ This way, your layout stays Server Component,
andHolidayBannersafely runs as a Client Component using browser time.
- On SSR environments (Next.js, Remix), we do not render the banner on the server.
- After hydration on the client, the component reads the browser’s local time and decides which holiday to show.
- This avoids timezone drift, DST issues, and “server time vs user time” inconsistencies.
- You can still force a time in dev/preview via
holidaysDateOverridefor deterministic testing.
export type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
export interface DateSingle {
type: "single";
month: Month;
day: number; // 1–31
}
export interface DateRange {
start: { month: Month; day: number };
end: { month: Month; day: number };
inclusive?: boolean; // default: true
}
export interface DateMultiEntry {
month: Month;
day: number;
}
export interface DateMulti {
type: "multi";
entries: DateMultiEntry[];
}
export interface Schedule {
years?: number[]; // limit to specific years
daysOfWeek?: (1 | 2 | 3 | 4 | 5 | 6 | 7)[]; // ISO: Mon=1..Sun=7
timeWindow?: { start: string; end: string }; // "HH:mm" local time
}
export interface Display {
placement?: "top"; // reserved for future
layout?: "full" | "container"; // visual width intent (no CSS required)
position?: "static" | "sticky" | "fixed";
}
export interface ContentImage {
src: string;
alt: string;
position?: "left" | "right";
maxHeight?: string; // e.g. "32px"
width?: string; // e.g. "24px"
}
export interface StyleOptions {
background?: string;
textColor?: string;
linkColor?: string;
height?: string; // e.g. "56px"
paddingX?: string; // e.g. "24px"
fontSize?: string | number;
fontWeight?: number | string;
gap?: string; // e.g. "12px"
border?: string; // e.g. "1px solid #e5e5e5"
customClass?: string; // applied to inner row
containerClass?: string; // applied to outer wrapper
inlineStyle?: React.CSSProperties; // merged last
align?: "left" | "center" | "right";
textAlign?: "left" | "center" | "right";
zIndex?: number;
}
export interface Holiday {
id: string;
title?: string;
active?: boolean; // default: true
date?: DateSingle;
range?: DateRange;
multi?: DateMultiEntry[];
schedule?: Schedule;
display?: Display;
content?: { text?: string; image?: ContentImage };
style?: StyleOptions;
priority?: number; // higher wins on collisions (default 0)
tags?: string[];
notes?: string;
}
export interface HolidayProps {
holidaysData: Holiday[];
holidaysDateOverride?: Date; // test helper
className?: string; // extra class on outer wrapper
}- The component computes
today = { month, day }from clientDate. - A record matches if:
active !== false, and- one of the date shapes matches:
date(single day) orrange(may wrap year end; inclusive by default) ormulti(any of listed month/day pairs),
- and the optional
schedulematches:years: include current yeardaysOfWeek: include today’s ISO weekday (Mon=1..Sun=7)timeWindow: “HH:mm” in local time; supports windows that cross midnight.
- If multiple records match, the component picks the one with the highest
priority.
Ties are broken byid(alphabetically).
{ id: "republic-day", active: true, date: { type: "single", month: 10, day: 29 } }{
id: "new-year",
range: { start: { month: 12, day: 31 }, end: { month: 1, day: 1 }, inclusive: true }
}{
id: "payday",
multi: [{ month: 1, day: 15 }, { month: 2, day: 15 }, { month: 3, day: 15 }]
}{
id: "friday-promo",
date: { type: "single", month: 11, day: 7 },
schedule: {
daysOfWeek: [5], // Fridays only (Mon=1..Fri=5)
timeWindow: { start: "09:00", end: "17:30" }
}
}{
id: "sticky-warning",
date: { type: "single", month: 6, day: 1 },
display: { layout: "container", position: "sticky" },
style: {
background: "#fff7ed",
textColor: "#9a3412",
height: "48px",
paddingX: "16px",
align: "left",
textAlign: "left",
zIndex: 100,
border: "1px solid #fed7aa",
containerClass: "max-w-6xl mx-auto", // Tailwind users
customClass: "text-sm md:text-base"
},
content: {
text: "Summer schedule is live. Check updates."
}
}{ id: "image-only", date: { type: "single", month: 4, day: 23 }, content: { image: { src: "/flag.png", alt: "Flag" } } }
{ id: "text-only", date: { type: "single", month: 5, day: 19 }, content: { text: "Commemoration of Atatürk, Youth and Sports Day" } }| Prop | Type | Default | Description |
|---|---|---|---|
holidaysData |
Holiday[] |
— | Records to evaluate for today. |
holidaysDateOverride |
Date |
— | Forces a specific time (testing). Otherwise uses browser time. |
className |
string |
— | Extra class on outer wrapper. |
Only one banner is rendered. If nothing matches, component returns
null.
- You don’t need Tailwind/CSS frameworks.
StyleOptionscovers typical needs (background,textColor,height,gap,paddingX,border, etc.).- For layout constraints (centered content in a max‑width), pass
style.containerClass(e.g. Tailwindmax-w-7xl mx-auto). display.position: "sticky" | "fixed"controls how the bar attaches to the top; usezIndexwhen necessary.
TypeScript source is recommended. If you prefer JSON:
- Enable JSON imports in your bundler/tsconfig (
"resolveJsonModule": true). - Provide a type assertion when importing to keep types:
import type { Holiday } from "react-holiday-banner";
import holidaysJson from "./holidays.json";
const holidaysData = holidaysJson as Holiday[];If your toolchain doesn’t allow JSON imports, convert the file to
holidays.tsandexport default [...].
- “Rendered more hooks than during the previous render” — ensure hooks are not called conditionally. The component already stabilizes inputs and uses a client‑only clock.
- Banner not visible on SSR — expected; it’s rendered after hydration. Use
holidaysDateOverrideto test in dev. - Multiple matches — increase
priorityto control which banner wins. - Positioning/CSS collisions — set
display.position = "sticky"and tunestyle.zIndex. For constrained width, setstyle.containerClass.
- SemVer (
MAJOR.MINOR.PATCH). - Public API:
HolidayBanner+ exported types inindex.d.ts. - No runtime deps; if we add one in the future, it will be declared properly.
PRs welcome. Please run local build & typecheck:
npm run buildMIT © 2025 Erhan Akkaya