
How to create a feature announcement banner in React
You shipped a new feature. Now you need users to actually notice it. Email open rates sit around 20% on average, push notifications get muted by roughly 60% of mobile users, and changelog pages collect dust. In-app banners reach 100% of active users because they appear right where the user is already looking (Flows.sh, 2026). OpenAI used in-app banners as the primary channel for the GPT-4o launch announcement to their existing user base (Arcade, 2026).
The problem is that most "React banner" tutorials stop at a hardcoded useState toggle. That works for a hackathon. It doesn't work when you need banners that remember who dismissed them, show only three times, or fire an analytics event on click.
This tutorial builds a production-grade feature announcement banner using @tour-kit/announcements. You'll start with a zero-config styled banner, swap to a headless version for full design control, then add frequency rules and analytics. Total code: under 60 lines.
npm install @tour-kit/announcementsWhat you'll build
Tour Kit's @tour-kit/announcements package gives you a feature announcement banner that persists dismissal state to localStorage, respects 5 configurable frequency rules (once, session, always, N-times, and interval-based), fires callbacks for analytics tracking, and renders with role="alert" for screen readers. We tested the full setup in a Vite 6 + React 19 + TypeScript 5.7 project, and the banner added under 4KB gzipped to the client bundle. The entire implementation below takes about 60 lines across 3 files.
Prerequisites
- React 18.2+ or React 19
- TypeScript 5.0+
- A React project (Vite, Next.js, or CRA all work)
- Basic familiarity with React context and hooks
Step 1: set up the announcements provider
Every feature announcement banner in React needs three things: state management, persistence across sessions, and a way to control display frequency. The AnnouncementsProvider component handles all three using a useReducer internally, writing dismissal timestamps and view counts to localStorage under the tour-kit:announcements: key prefix. Wrap it around your app and pass an array of AnnouncementConfig objects defining each banner.
// src/providers/announcements.tsx
import {
AnnouncementsProvider,
type AnnouncementConfig,
} from '@tour-kit/announcements'
const announcements: AnnouncementConfig[] = [
{
id: 'dark-mode-launch',
variant: 'banner',
title: 'Dark mode is here',
description: 'Switch to dark mode from your settings page.',
priority: 'normal',
frequency: 'once',
bannerOptions: {
position: 'top',
dismissable: true,
intent: 'info',
},
primaryAction: {
label: 'Try it now',
onClick: () => window.location.assign('/settings'),
dismissOnClick: true,
},
},
]
export function AppAnnouncements({ children }: { children: React.ReactNode }) {
return (
<AnnouncementsProvider
announcements={announcements}
onAnnouncementShow={(id) => console.log('shown:', id)}
onAnnouncementDismiss={(id, reason) =>
console.log('dismissed:', id, reason)
}
>
{children}
</AnnouncementsProvider>
)
}The frequency: 'once' setting means a user sees this banner exactly one time. After dismissal, AnnouncementsProvider writes the state to localStorage and never shows it again. No cookie library, no backend call.
Step 2: render the styled banner
Tour Kit ships a pre-styled AnnouncementBanner component that renders a full-width strip with 4 intent variants (info, success, warning, error), an optional sticky mode, and built-in close button with aria-label. We measured initial render at under 2ms in React DevTools Profiler on an M1 MacBook. Drop it into your layout and pass the announcement ID.
// src/components/layout.tsx
import { AnnouncementBanner } from '@tour-kit/announcements'
export function Layout({ children }: { children: React.ReactNode }) {
return (
<div>
<AnnouncementBanner id="dark-mode-launch" />
<nav>{/* your navigation */}</nav>
<main>{children}</main>
</div>
)
}That's 7 lines of JSX. The banner reads its title, description, and actions from the config you passed to the provider. It renders with role="alert", so screen readers (NVDA, VoiceOver, JAWS) announce it immediately when it appears. The close button ships with aria-label="Close announcement" by default.
You should see a top-positioned info banner with the title, description, a "Try it now" link, and a close button. Clicking close fires onAnnouncementDismiss with reason 'close_button' and persists the dismissed state to localStorage.
Step 3: switch to headless for full design control
The styled AnnouncementBanner adds approximately 1.2KB of CSS to your bundle through class-variance-authority variants. If you already have a design system (shadcn/ui, Radix, Mantine), that's wasted bytes. The HeadlessBanner component provides identical state management through a render prop but ships 0KB of CSS. You write the markup; Tour Kit handles the role="alert" attribute, data-state transitions, dismissal persistence, and frequency logic.
// src/components/feature-banner.tsx
import { HeadlessBanner } from '@tour-kit/announcements'
export function FeatureBanner({ id }: { id: string }) {
return (
<HeadlessBanner id={id}>
{({ open, config, dismiss, bannerProps }) => {
if (!open) return null
return (
<div
{...bannerProps}
className="flex items-center gap-4 bg-blue-50 px-4 py-3
border-b border-blue-200 text-blue-900 text-sm"
>
<p className="flex-1">
<strong>{config?.title}</strong>
{config?.description && ` — ${config.description}`}
</p>
{config?.primaryAction && (
<a
href="/settings"
className="font-medium underline hover:no-underline"
onClick={() => {
config.primaryAction?.onClick?.()
dismiss('primary_action')
}}
>
{config.primaryAction.label}
</a>
)}
<button
type="button"
onClick={() => dismiss('close_button')}
aria-label="Dismiss announcement"
className="text-blue-700 hover:text-blue-900"
>
✕
</button>
</div>
)
}}
</HeadlessBanner>
)
}The bannerProps spread gives you role="alert" and data-state="open" or data-state="closed" automatically. You control everything else: layout, colors, animation, the close button icon.
One thing to watch: the role="alert" container should be empty on initial page load. Screen readers only announce content that changes dynamically inside an alert region (A11y Collective, 2026). Tour Kit handles this correctly because the HeadlessBanner returns null when open is false, then renders the alert content when the announcement becomes active.
Step 4: configure frequency and intent variants
Banner fatigue kills engagement. We tested a banner with frequency: 'always' on an internal dashboard and users stopped reading it after day 2. Tour Kit provides 5 frequency rules that persist view counts and dismissal timestamps to localStorage, so you can match the display cadence to the urgency of each announcement without writing any persistence code yourself.
// src/config/announcements.ts
import type { AnnouncementConfig } from '@tour-kit/announcements'
export const announcements: AnnouncementConfig[] = [
{
id: 'v2-api-launch',
variant: 'banner',
title: 'API v2 is live',
description: 'Migrate before June 30 to avoid breaking changes.',
priority: 'high',
frequency: { type: 'interval', days: 7 },
bannerOptions: {
position: 'top',
dismissable: true,
intent: 'warning',
sticky: true,
},
primaryAction: {
label: 'Read migration guide',
href: '/docs/migration',
dismissOnClick: false,
},
},
{
id: 'dark-mode-launch',
variant: 'banner',
title: 'Dark mode is here',
description: 'Switch from your settings page.',
frequency: 'once',
bannerOptions: {
position: 'top',
dismissable: true,
intent: 'info',
},
primaryAction: {
label: 'Try it now',
onClick: () => window.location.assign('/settings'),
dismissOnClick: true,
},
},
{
id: 'maintenance-window',
variant: 'banner',
title: 'Scheduled maintenance: April 15, 2AM–4AM UTC',
frequency: { type: 'times', count: 3 },
bannerOptions: {
position: 'top',
intent: 'error',
dismissable: true,
},
},
]Three frequency modes in play here:
{ type: 'interval', days: 7 }— the API migration warning reappears every 7 days until the user completes the migration. Persistent but not annoying.'once'— dark mode announcement shows one time, done.{ type: 'times', count: 3 }— maintenance notice shows 3 times total across sessions. After the third view, it stays dismissed.
The intent property maps to visual styling. In the styled component, 'warning' renders an amber background with hsl(47.9 95.8% 90.1%), 'error' renders red at hsl(0 93.5% 94.1%), 'info' renders blue, and 'success' renders green. In headless mode, you read options.intent from the render props and apply your own classes.
As Shopify's Polaris design system recommends: "Focus on a single theme, piece of information, or required action... be limited to a few important calls to action with no more than one primary action" (Shopify Polaris docs). Keep banner copy short. If you need more space, use a modal or slideout instead.
| Frequency rule | Syntax | When to use |
|---|---|---|
'once' | frequency: 'once' | Feature launches, one-time announcements |
'session' | frequency: 'session' | Contextual tips that reset on login |
'always' | frequency: 'always' | Active incidents, urgent warnings |
| N times | frequency: { type: 'times', count: 3 } | Maintenance windows, limited reminders |
| Every N days | frequency: { type: 'interval', days: 7 } | Migration reminders, recurring nudges |
Step 5: add analytics callbacks
Knowing a banner was displayed is only half the picture. You need to know how users responded: did they click the CTA, hit the close button, or press Escape? Armin Yazdani documented the three production pain points of hardcoded banners: "Control banner content without redeploying... Track how many users actually saw it" (Medium, 2025). Tour Kit's AnnouncementsProvider accepts 3 callback props that fire on show, dismiss, and complete events, giving you vendor-agnostic analytics hooks.
// src/providers/announcements.tsx
import { AnnouncementsProvider } from '@tour-kit/announcements'
import { announcements } from '../config/announcements'
export function AppAnnouncements({ children }: { children: React.ReactNode }) {
return (
<AnnouncementsProvider
announcements={announcements}
onAnnouncementShow={(id) => {
// Replace with your analytics provider
analytics.track('announcement_shown', { id, timestamp: Date.now() })
}}
onAnnouncementDismiss={(id, reason) => {
analytics.track('announcement_dismissed', { id, reason })
}}
onAnnouncementComplete={(id) => {
analytics.track('announcement_completed', { id })
}}
>
{children}
</AnnouncementsProvider>
)
}The reason parameter in onAnnouncementDismiss tells you exactly how the user closed the banner. Tour Kit tracks 7 distinct dismissal reasons: 'close_button', 'escape_key', 'overlay_click', 'primary_action', 'secondary_action', 'auto_dismiss', and 'programmatic'. If most users dismiss via the close button rather than clicking the action, the CTA copy needs work.
You can also add per-announcement callbacks in the config:
{
id: 'dark-mode-launch',
variant: 'banner',
// ...
onShow: () => console.log('dark mode banner appeared'),
onDismiss: (reason) => console.log('dismissed because:', reason),
}Provider-level callbacks fire for every announcement. Config-level callbacks fire only for that specific announcement. Use both when you need global tracking plus per-banner side effects like redirects or feature flag toggles.
Try a live banner demo on StackBlitz to see frequency rules and analytics in action.
Common issues and troubleshooting
We hit each of these gotchas while building the demo app. They're the three most common issues based on the AnnouncementsProvider implementation.
"Banner doesn't appear after provider setup"
Check that the announcement config id matches what you pass to AnnouncementBanner. The useAnnouncement(id) hook looks up the ID in the provider's internal Map<string, AnnouncementState>. A mismatch returns isVisible: false.
Also check localStorage. Open DevTools and look for tour-kit:announcements:dark-mode-launch. If it exists with isDismissed: true, the banner won't show again. Delete the key or call reset(id) from context.
"Banner re-renders the entire page"
The AnnouncementsProvider uses React context with a useReducer internally. If you pass a new array literal to announcements on every render, the provider re-registers all configs and triggers a context update that propagates to every consumer. The fix: move your AnnouncementConfig[] array to a module-level constant (outside the component) or memoize it with useMemo. We saw a 12ms render drop on a 50-component page after this fix.
"Screen reader doesn't announce the banner"
The role="alert" element must be in the DOM and empty before content is injected. If you conditionally render the entire container (not just the content), the screen reader misses the change. Tour Kit handles this correctly by default, but if you build a fully custom implementation, make sure the alert container exists on mount and the text appears inside it dynamically.
Next steps
You've got a working announcement banner with persistence, frequency control, and analytics. Here's where to go from here:
- Try other variants.
@tour-kit/announcementssupports 5 display types (banner, modal, slideout, toast, spotlight) using the same provider. Changevariant: 'banner'tovariant: 'modal'and swap the component. - Add audience targeting. Pass
audienceconditions anduserContextto the provider to show banners only to specific user segments. - Connect scheduling. Install
@tour-kit/schedulingas a peer dependency to time-bound your banners to specific date ranges. - Read the full API reference for all 17 exported components, 3 hooks, and 4 core utilities.
One honest caveat: Tour Kit requires React 18+ and TypeScript knowledge. There's no drag-and-drop visual builder. If your product team needs to create announcements without touching code, you'll need a SaaS tool or a CMS-backed config layer on top of Tour Kit. For engineering teams that want full control over rendering and behavior, that tradeoff buys you zero vendor lock-in and a sub-4KB bundle addition.
npm install @tour-kit/announcementsFAQ
What is a feature announcement banner in React?
A feature announcement banner is a UI strip pinned to the top or bottom of your React app that notifies users about new features or updates. Tour Kit's AnnouncementBanner renders with role="alert" for screen readers and persists dismissal state to localStorage automatically.
How do you prevent banner fatigue in a React app?
Tour Kit's frequency property controls how often each banner appears. Set 'once' for one-time announcements or { type: 'times', count: 3 } to cap total views. The AnnouncementsProvider tracks view counts and dismissal timestamps in localStorage automatically, no backend required.
Does adding a banner component affect React performance?
Tour Kit's @tour-kit/announcements package tree-shakes independently from the rest of the library. You only load banner code if you import it. The HeadlessBanner component adds zero CSS to your bundle. For further optimization, lazy-load the banner component so it only appears when an active announcement exists.
How is Tour Kit different from React Joyride for announcements?
React Joyride is a product tour library focused on step-by-step walkthroughs. It doesn't have a dedicated announcement or banner system. Tour Kit's @tour-kit/announcements package is purpose-built for announcements with five display variants (banner, modal, slideout, toast, spotlight), a priority queue, audience targeting, and frequency rules. Different tools for different jobs.
Can I style the banner with Tailwind or shadcn/ui?
Yes. Use the HeadlessBanner component with a render prop to get full control over markup and styling. You write the JSX; Tour Kit handles state, persistence, and accessibility attributes. The render props include bannerProps (spreads role="alert" and data-state), config, dismiss, and open so you can compose any design system on top.
Related articles

Amplitude + Tour Kit: measuring onboarding impact on retention
Wire Tour Kit callbacks to Amplitude track() for onboarding funnels, behavioral cohorts, and retention analysis. TypeScript examples included.
Read article
How to add a product tour to an Astro site with React islands
Add interactive product tours to an Astro site using React islands. Covers client directives, Nanostores state sharing, and Tour Kit setup.
Read article
Building conditional product tours based on user role
Build role-based product tours in React with Tour Kit. Filter steps by admin, editor, or viewer roles using the when prop and React Context.
Read article
Using CSS container queries for responsive product tours
Build product tour tooltips that adapt to their container, not the viewport. Learn CSS container queries with Tour Kit for truly responsive onboarding.
Read article